Downloads containing npcBunniesAccessories.asc

Downloads
Name Author Game Mode Rating
TSF with JJ2+ Only: NpcBunnies Cranky Mutator N/A Download file

File preview

namespace npcBunniesAccessories
{
namespace NpcAccessories
{
	const int CATEGORY_HAT = 0;
	const int CATEGORY_SCARF = 1;
	const int CATEGORY_GLASSES = 2;
	const int CATEGORY_COUNT = 3;
	const bool NPC_DEBUG_HAT_POSITION_ON = false;
	bool ENABLE_HAT_ONLY_FOR_NPC_CHARACTERS = true; // False to enable hats for bosses, monsters, static as well
	int framesPerCharacter = 1;
	int characterSlotCount = 1;
	const int NO_ACCESSORY = -1;
	const int RUN_SHOOT_RABBIT_ANIM = RABBIT::DIVEFIRERIGHT;
	const int NPC_HAT_ACTION_CATEGORY = 9101;
	const int NPC_HAT_ACTION_TYPE = 9102;
	const int NPC_HAT_ACTION_STATE = 9103;
	const int NPC_HAT_ACTION_FRAME = 9104;
	const int NPC_HAT_ACTION_POS_X = 9105;
	const int NPC_HAT_ACTION_POS_Y = 9106;
	const int NPC_HAT_ACTION_ROTATION = 9107;
	const int NPC_HAT_ACTION_SCALE_X = 9108;
	const int NPC_HAT_ACTION_SCALE_Y = 9109;
	const int NPC_HAT_ACTION_INCREMENT = 9110;
	const int NPC_HAT_ACTION_EDIT_FIELD = 9111;
	const int NPC_HAT_ACTION_MOUSE = 9112;

	array<string> filenames = {"npcHats.asdat", "npcScarfs.asdat", "npcGlasses.asdat"};
	array<string> categoryNames = {"Hat", "Scarf", "Glasses"};
	array<string> stateNames = {"Idle", "Walk", "Run", "Fall", "Jump", "Shoot", "RunShoot"};
	array<int> stateNpcAnims = {0, 1, 2, 3, 4, 5, 6};
	array<int> stateRabbitAnims = {RABBIT::IDLE1, RABBIT::RUN1, RABBIT::RUN3, RABBIT::FALL, RABBIT::JUMPING1, RABBIT::FIRE, RUN_SHOOT_RABBIT_ANIM};
	array<array<int>> adjustX;
	array<array<int>> adjustY;
array<array<int>> rotation;
	array<array<float>> scaleX;
	array<array<float>> scaleY;
	array<array<bool>> edited;
	array<array<int>> nearestEditedIndex;
	array<int> lazyAccessoryLookupGeneration;
	array<int> lazyAccessoryNpcType;
	array<int> lazyAccessoryCategoryFrame;
	array<int> lazyAccessoryIndex;
	array<int> lazyAccessoryCoordinateFrameIndex;
	array<int> lazyAccessoryStoredFrameIndex;
	array<int> lazyAccessoryPhysicalFrameIndex;
	array<int> lazyStateFrameCountGeneration;
	array<int> lazyStateFrameCountType;
	array<int> lazyStateFrameCountValue;
	array<int> cachedNpcAccessoryType;
	array<int> cachedNpcAccessoryHatFrame;
	array<int> cachedNpcAccessoryScarfFrame;
	array<int> cachedNpcAccessoryGlassesFrame;
	array<bool> cachedNpcHasDrawableAccessory;
	array<array<int>> minAnimStart;
	array<array<int>> minAnimCount;
	int accessorySet = -1;
	bool accessoryLookupDirty = true;
	int accessoryLookupGeneration = 0;
	bool loaded = false;
	bool editorOpen = false;
	bool editorMouseMode = false;
	bool editorMouseWasDown = false;
	bool editorPressedKey = false;
	bool editorPressedFire = false;
	int editorCategory = 0;
	int editorTypeIndex = 0;
	int editorState = 0;
	int editorFrame = 0;
	int editorX = 0;
	int editorY = 0;
	int editorRotation = 0;
	float editorScaleX = 1.0;
	float editorScaleY = 1.0;
	int editorIncrement = 1;
	int editorField = -1;
	string editorFieldValue = "";
	bool editorFieldFresh = false;
	int editorLastKey = -1;
	DbWindow editorWindow;
	DbResult editorResult;

	int PositionArrayLength()
	{
		return characterSlotCount * framesPerCharacter + 1;
	}

	void ResizePositionArrays(int newCharacterSlotCount, int newFramesPerCharacter = -1)
	{
		if(newCharacterSlotCount < 1) newCharacterSlotCount = 1;
		if(newFramesPerCharacter < 1) newFramesPerCharacter = framesPerCharacter;
		if(newFramesPerCharacter < 1) newFramesPerCharacter = 1;

		if(newCharacterSlotCount == characterSlotCount
		&& newFramesPerCharacter == framesPerCharacter
		&& int(adjustX.length) == CATEGORY_COUNT
		&& int(adjustX[0].length) == characterSlotCount * framesPerCharacter + 1) return;

		int oldCharacterSlotCount = characterSlotCount;
		int oldFramesPerCharacter = framesPerCharacter;
		int oldPositionLength = oldCharacterSlotCount * oldFramesPerCharacter + 1;
		array<array<int>> oldAdjustX = adjustX;
		array<array<int>> oldAdjustY = adjustY;
		array<array<int>> oldRotation = rotation;
		array<array<float>> oldScaleX = scaleX;
		array<array<float>> oldScaleY = scaleY;
		array<array<bool>> oldEdited = edited;

		characterSlotCount = newCharacterSlotCount;
		framesPerCharacter = newFramesPerCharacter;
		int newPositionLength = PositionArrayLength();
		adjustX.resize(CATEGORY_COUNT);
		adjustY.resize(CATEGORY_COUNT);
		rotation.resize(CATEGORY_COUNT);
		scaleX.resize(CATEGORY_COUNT);
		scaleY.resize(CATEGORY_COUNT);
		edited.resize(CATEGORY_COUNT);
		nearestEditedIndex.resize(CATEGORY_COUNT);

		for(int category = 0; category < CATEGORY_COUNT; category++)
		{
			adjustX[category].resize(newPositionLength);
			adjustY[category].resize(newPositionLength);
			rotation[category].resize(newPositionLength);
			scaleX[category].resize(newPositionLength);
			scaleY[category].resize(newPositionLength);
			edited[category].resize(newPositionLength);
			nearestEditedIndex[category].resize(newPositionLength);
			for(int i = 0; i < newPositionLength; i++)
			{
				adjustX[category][i] = 0;
				adjustY[category][i] = 0;
				rotation[category][i] = 0;
				scaleX[category][i] = 1.0;
				scaleY[category][i] = 1.0;
				edited[category][i] = false;
				nearestEditedIndex[category][i] = -1;
			}
		}

		int copySlots = oldCharacterSlotCount < newCharacterSlotCount ? oldCharacterSlotCount : newCharacterSlotCount;
		int copyFrames = oldFramesPerCharacter < newFramesPerCharacter ? oldFramesPerCharacter : newFramesPerCharacter;
		for(int category = 0; category < CATEGORY_COUNT; category++)
		{
			for(int slot = 0; slot < copySlots; slot++)
			{
				for(int frame = 0; frame < copyFrames; frame++)
				{
					int oldIndex = slot * oldFramesPerCharacter + frame;
					int newIndex = slot * newFramesPerCharacter + frame;
					if(category >= int(oldEdited.length)) continue;
					if(oldIndex < 0 || oldIndex >= int(oldEdited[category].length) || newIndex < 0 || newIndex >= newPositionLength) continue;
					adjustX[category][newIndex] = oldAdjustX[category][oldIndex];
					adjustY[category][newIndex] = oldAdjustY[category][oldIndex];
					rotation[category][newIndex] = oldRotation[category][oldIndex];
					scaleX[category][newIndex] = oldScaleX[category][oldIndex];
					scaleY[category][newIndex] = oldScaleY[category][oldIndex];
					edited[category][newIndex] = oldEdited[category][oldIndex];
				}
			}
		}
		accessoryLookupDirty = true;
	}

	void EnsurePositionIndexFits(int charSlot, int frameIndex)
	{
		if(charSlot < 0) charSlot = 0;
		if(frameIndex < 0) frameIndex = 0;
		int neededSlots = characterSlotCount;
		int neededFrames = framesPerCharacter;
		if(charSlot >= neededSlots) neededSlots = charSlot + 1;
		if(frameIndex >= neededFrames) neededFrames = frameIndex + 1;
		if(int(adjustX.length) != CATEGORY_COUNT || neededSlots != characterSlotCount || neededFrames != framesPerCharacter)
			ResizePositionArrays(neededSlots, neededFrames);
	}

int Index(int category, int charSlot, int frameIndex)
	{
		if(category < 0) category = 0;
		if(category >= CATEGORY_COUNT) category = CATEGORY_COUNT - 1;
		if(charSlot < 0) charSlot = 0;
		if(frameIndex < 0) frameIndex = 0;
		EnsurePositionIndexFits(charSlot, frameIndex);
		return charSlot * framesPerCharacter + frameIndex;
	}

	int ExistingIndex(int category, int charSlot, int frameIndex)
	{
		if(category < 0 || category >= CATEGORY_COUNT) return -1;
		if(charSlot < 0 || frameIndex < 0) return -1;
		if(charSlot >= characterSlotCount || frameIndex >= framesPerCharacter) return -1;
		int index = charSlot * framesPerCharacter + frameIndex;
		if(category >= int(adjustX.length) || index < 0 || index >= int(adjustX[category].length)) return -1;
		return index;
	}

	void RebuildNearestEditedIndexes()
	{
		int positionLength = PositionArrayLength();
		nearestEditedIndex.resize(CATEGORY_COUNT);
		for(int category = 0; category < CATEGORY_COUNT; category++)
		{
			nearestEditedIndex[category].resize(positionLength);
			for(int i = 0; i < positionLength; i++) nearestEditedIndex[category][i] = -1;
			for(int slot = 0; slot < characterSlotCount; slot++)
			{
				int lastEdited = -1;
				for(int frame = 0; frame < framesPerCharacter; frame++)
				{
					int index = slot * framesPerCharacter + frame;
					if(index >= 0 && index < int(edited[category].length) && edited[category][index]) lastEdited = index;
					nearestEditedIndex[category][index] = lastEdited;
				}

				int nextEdited = -1;
				for(int frame = framesPerCharacter - 1; frame >= 0; frame--)
				{
					int index = slot * framesPerCharacter + frame;
					if(index >= 0 && index < int(edited[category].length) && edited[category][index]) nextEdited = index;
					if(nextEdited < 0) continue;
					int current = nearestEditedIndex[category][index];
					if(current < 0)
					{
						nearestEditedIndex[category][index] = nextEdited;
					}
					else
					{
						int leftFrame = current % framesPerCharacter;
						int rightFrame = nextEdited % framesPerCharacter;
						if(rightFrame - frame < frame - leftFrame) nearestEditedIndex[category][index] = nextEdited;
					}
				}
			}
		}
		accessoryLookupDirty = false;
		accessoryLookupGeneration++;
	}

	void EnsureAccessoryLookupCache()
	{
		if(accessoryLookupDirty) RebuildNearestEditedIndexes();
	}

	void EnsureLazyAccessoryCacheIndex(int index)
	{
		if(index < 0 || int(lazyAccessoryIndex.length) > index) return;
		int needed = index + 1;
		int oldLength = int(lazyAccessoryIndex.length);
		lazyAccessoryLookupGeneration.resize(needed);
		lazyAccessoryNpcType.resize(needed);
		lazyAccessoryCategoryFrame.resize(needed);
		lazyAccessoryIndex.resize(needed);
		lazyAccessoryCoordinateFrameIndex.resize(needed);
		lazyAccessoryStoredFrameIndex.resize(needed);
		lazyAccessoryPhysicalFrameIndex.resize(needed);
		for(int i = oldLength; i < needed; i++)
		{
			lazyAccessoryLookupGeneration[i] = -1;
			lazyAccessoryNpcType[i] = 0;
			lazyAccessoryCategoryFrame[i] = -9999;
			lazyAccessoryIndex[i] = -1;
			lazyAccessoryCoordinateFrameIndex[i] = -1;
			lazyAccessoryStoredFrameIndex[i] = -1;
			lazyAccessoryPhysicalFrameIndex[i] = -1;
		}
	}

	int LazyAccessoryCacheIndex(int npcId, int state, int frame, int category)
	{
		if(npcId < 0 || state < 0 || state >= int(stateNpcAnims.length) || frame < 0 || frame >= framesPerCharacter || category < 0 || category >= CATEGORY_COUNT) return -1;
		int index = ((npcId * int(stateNpcAnims.length) + state) * framesPerCharacter + frame) * CATEGORY_COUNT + category;
		EnsureLazyAccessoryCacheIndex(index);
		return index;
	}

	void EnsureLazyStateFrameCountCacheIndex(int index)
	{
		if(index < 0 || int(lazyStateFrameCountValue.length) > index) return;
		int needed = index + 1;
		int oldLength = int(lazyStateFrameCountValue.length);
		lazyStateFrameCountGeneration.resize(needed);
		lazyStateFrameCountType.resize(needed);
		lazyStateFrameCountValue.resize(needed);
		for(int i = oldLength; i < needed; i++)
		{
			lazyStateFrameCountGeneration[i] = -1;
			lazyStateFrameCountType[i] = 0;
			lazyStateFrameCountValue[i] = 1;
		}
	}

	int LazyStateFrameCountCacheIndex(int npcId, int state)
	{
		if(npcId < 0 || state < 0 || state >= int(stateNpcAnims.length)) return -1;
		int index = npcId * int(stateNpcAnims.length) + state;
		EnsureLazyStateFrameCountCacheIndex(index);
		return index;
	}

	void EnsureNpcAccessoryStateCache(int npcId)
	{
		if(npcId < 0) return;
		int needed = npcId + 1;
		if(int(cachedNpcHasDrawableAccessory.length) >= needed) return;
		int oldLength = int(cachedNpcHasDrawableAccessory.length);
		cachedNpcAccessoryType.resize(needed);
		cachedNpcAccessoryHatFrame.resize(needed);
		cachedNpcAccessoryScarfFrame.resize(needed);
		cachedNpcAccessoryGlassesFrame.resize(needed);
		cachedNpcHasDrawableAccessory.resize(needed);
		for(int i = oldLength; i < needed; i++)
		{
			cachedNpcAccessoryType[i] = 0;
			cachedNpcAccessoryHatFrame[i] = -9999;
			cachedNpcAccessoryScarfFrame[i] = -9999;
			cachedNpcAccessoryGlassesFrame[i] = -9999;
			cachedNpcHasDrawableAccessory[i] = false;
		}
	}

	void RefreshNpcAccessoryStateCache(npcBunniesCore::npcBunny &in npc)
	{
		if(npc.id < 0) return;
		EnsureNpcAccessoryStateCache(npc.id);
		if(cachedNpcAccessoryType[npc.id] == npc.type
		&& cachedNpcAccessoryHatFrame[npc.id] == npc.hatFrame
		&& cachedNpcAccessoryScarfFrame[npc.id] == npc.scarfFrame
		&& cachedNpcAccessoryGlassesFrame[npc.id] == npc.glassesFrame)
			return;

		cachedNpcAccessoryType[npc.id] = npc.type;
		cachedNpcAccessoryHatFrame[npc.id] = npc.hatFrame;
		cachedNpcAccessoryScarfFrame[npc.id] = npc.scarfFrame;
		cachedNpcAccessoryGlassesFrame[npc.id] = npc.glassesFrame;
		cachedNpcHasDrawableAccessory[npc.id] = (npc.scarfFrame >= 0 || npc.glassesFrame >= 0 || (npc.hatFrame >= 0 && CanNpcUseHat(npc.type)));
	}

	bool NpcHasDrawableAccessory(npcBunniesCore::npcBunny &in npc)
	{
		if(npc.id < 0) return HasDrawableAccessory(npc);
		RefreshNpcAccessoryStateCache(npc);
		return cachedNpcHasDrawableAccessory[npc.id];
	}

	int StateFrameCountForNpcType(int npcType, int state)
	{
		int stateAnim = state >= 0 && state < int(stateNpcAnims.length) ? stateNpcAnims[state] : 0;
		int count = SpecialEditorPreviewFrameCount(npcType, state);
		if(count <= 0)
		{
			int sourceSet = CharactersModSourceSetForNpcType(npcType, stateAnim);
			if(sourceSet >= 0) count = npcBunniesAnimations::charactersModAnimCount[sourceSet][stateAnim];
		}
		if(count <= 0) count = framesPerCharacter;
		if(count <= 0) count = 1;
		if(count > framesPerCharacter) count = framesPerCharacter;
		return count;
	}

	int StateFrameCountForNpc(npcBunniesCore::npcBunny &in npc, int state)
	{
		int cacheIndex = LazyStateFrameCountCacheIndex(npc.id, state);
		if(cacheIndex >= 0
		&& lazyStateFrameCountGeneration[cacheIndex] == accessoryLookupGeneration
		&& lazyStateFrameCountType[cacheIndex] == npc.type)
			return lazyStateFrameCountValue[cacheIndex];

		int count = StateFrameCountForNpcType(npc.type, state);
		if(cacheIndex >= 0)
		{
			lazyStateFrameCountGeneration[cacheIndex] = accessoryLookupGeneration;
			lazyStateFrameCountType[cacheIndex] = npc.type;
			lazyStateFrameCountValue[cacheIndex] = count;
		}
		return count;
	}

	int GetAnimationDivisionForCharacterSlot(int charSlot)
	{
		// Use the resolved character slot, not the raw npc.type value.
		// Some NPCs use getTypeFromTypeName(...) values instead of NpcAnim::*
		// constants, so checking only npc.type missed Jazz/Spaz/Lori aliases
		// and made runtime color-layer frames look for unsaved positions.
		if(charSlot == 1) return 5; // Jazz: full + 4 color-removed versions
		if(charSlot == 2) return 5; // Spaz: full + 4 color-removed versions
		if(charSlot == 3) return 4; // Lori: full + 3 color-removed versions
		if(charSlot == 5) return 4; // Bird: full + 3 color-removed versions
		if(charSlot == 8) return 5; // Mario: full + 4 color-removed versions
		if(charSlot == 9) return 5; // Sonic: full + 4 color-removed versions
		if(charSlot == 10) return 5; // Tails: full + 4 color-removed versions
		if(charSlot == 11) return 5; // JJ1: full + 4 color-removed versions
		if(charSlot == 12) return 5; // Devan: full + 4 color-removed versions
		if(charSlot == 13) return 5; // Yoshi: full + 4 color-removed versions
		return 1;
	}

	int GetAnimationDivisionForNpcType(int type)
	{
		return GetAnimationDivisionForCharacterSlot(CharacterSlotForNpcType(type));
	}

	int GetMergedAccessoryFrame(int frameIndex, int division, int layer = 0)
	{
		if(division <= 1) return frameIndex;
		if(layer < 0) layer = 0;
		if(layer >= division) layer = division - 1;
		
		int layerFrameCount = framesPerCharacter / division;
		if(layerFrameCount <= 0) return frameIndex;
		
		int frameInLayer = frameIndex % layerFrameCount;
		if(frameInLayer < 0) frameInLayer += layerFrameCount;
		return layer * layerFrameCount + frameInLayer;
	}

	void EnsureLoaded()
	{
		if(loaded) return;
		loaded = true;
		accessorySet = 0;
		while(jjAnimSets[ANIM::CUSTOM[accessorySet]].firstAnim != 0) accessorySet++;
		jjAnimSets[ANIM::CUSTOM[accessorySet]].load(0, "xmasHats.j2a");
		ResizePositionArrays(InitialCharacterSlotCount(), 1);
		LoadCharactersMinIndexes();
		for(int category = 0; category < CATEGORY_COUNT; category++) ReadCategory(category);
		RebuildNearestEditedIndexes();
	}

	int InitialCharacterSlotCount()
	{
		int count = 1;
		for(int i = 0; i < int(npcBunniesCore::npcList.length); i++)
		{
			int slot = StorageSlotForCharacterSlot(CharacterSlotForNpcType(npcBunniesCore::npcList[i]));
			if(slot + 1 > count) count = slot + 1;
		}
		return count;
	}

	void EnsureMinAnimIndexFits(int sourceSet, int anim)
	{
		if(sourceSet < 0 || anim < 0) return;
		if(sourceSet >= int(minAnimStart.length))
		{
			minAnimStart.resize(sourceSet + 1);
			minAnimCount.resize(sourceSet + 1);
		}
		int oldLength = int(minAnimStart[sourceSet].length);
		if(anim >= oldLength)
		{
			minAnimStart[sourceSet].resize(anim + 1);
			minAnimCount[sourceSet].resize(anim + 1);
			for(int i = oldLength; i <= anim; i++)
			{
				minAnimStart[sourceSet][i] = -1;
				minAnimCount[sourceSet][i] = 0;
			}
		}
	}

	void LoadCharactersMinIndexes()
	{
		minAnimStart.resize(0);
		minAnimCount.resize(0);

		jjSTREAM file("charactersMinAnimIndexes.asdat");
		if(file.isEmpty()) return;
		string line = "";
		int currentSet = -1;
		while(file.getLine(line))
		{
			if(line == "" || line.substr(0, 1) == "/") continue;
			array<string> parts = line.split(" ");
			if(int(parts.length) == 0) continue;
			if(parts[0] == "set")
			{
				currentSet = int(parts.length) >= 2 ? parseInt(parts[1]) : -1;
				continue;
			}
			if(parts[0] == "end") { currentSet = -1; continue; }
			if(currentSet < 0 || int(parts.length) < 4) continue;
			int anim = parseInt(parts[0]);
			if(anim < 0) continue;
			EnsureMinAnimIndexFits(currentSet, anim);
			minAnimStart[currentSet][anim] = parseInt(parts[2]);
			minAnimCount[currentSet][anim] = parseInt(parts[3]);
		}
	}

	void ReadCategory(int category)
	{
		if(category < 0 || category >= CATEGORY_COUNT) return;
		jjSTREAM file(filenames[category]);
		if(file.isEmpty()) return;

		array<int> entryFrame;
		array<int> entrySlot;
		array<int> entryX;
		array<int> entryY;
		array<int> entryRotation;
		array<float> entryScaleX;
		array<float> entryScaleY;
		int maxSlot = characterSlotCount - 1;
		int maxFrame = framesPerCharacter - 1;

		string line = "";
		while(file.getLine(line))
		{
			array<string> parts = line.split(" ");
			if(int(parts.length) < 6) continue;
			int frameIndex = parseInt(parts[0]);
			int charSlot = parseInt(parts[5]) - 1;
			if(frameIndex < 0 || charSlot < 0) continue;
			entryFrame.insertLast(frameIndex);
			entrySlot.insertLast(charSlot);
			entryX.insertLast(parseInt(parts[1]));
			entryY.insertLast(parseInt(parts[2]));
			entryRotation.insertLast(parseInt(parts[3]));
			entryScaleX.insertLast(int(parts.length) >= 8 ? parseFloat(parts[6]) : 1.0);
			entryScaleY.insertLast(int(parts.length) >= 8 ? parseFloat(parts[7]) : 1.0);
			if(charSlot > maxSlot) maxSlot = charSlot;
			if(frameIndex > maxFrame) maxFrame = frameIndex;
		}

		if(maxSlot + 1 > characterSlotCount || maxFrame + 1 > framesPerCharacter)
			ResizePositionArrays(maxSlot + 1, maxFrame + 1);

		for(int i = 0; i < int(entryFrame.length); i++)
		{
			int index = Index(category, entrySlot[i], entryFrame[i]);
			adjustX[category][index] = entryX[i];
			adjustY[category][index] = entryY[i];
			rotation[category][index] = entryRotation[i];
			scaleX[category][index] = entryScaleX[i];
			scaleY[category][index] = entryScaleY[i];
			edited[category][index] = true;
		}
		accessoryLookupDirty = true;
	}

	void SaveCategory(int category)
	{
		if(category < 0 || category >= CATEGORY_COUNT) return;
		jjSTREAM file;
		for(int charSlot = 0; charSlot < characterSlotCount; charSlot++)
		{
			for(int frameIndex = 0; frameIndex < framesPerCharacter; frameIndex++)
			{
				int index = ExistingIndex(category, charSlot, frameIndex);
				if(index < 0 || !edited[category][index]) continue;
				file.write(frameIndex + " " + adjustX[category][index] + " " + adjustY[category][index] + " " + rotation[category][index] + " 0 " + (charSlot + 1) + " " + scaleX[category][index] + " " + scaleY[category][index] + " \n");
			}
		}
		file.save(filenames[category]);
	}

	int StorageSlotForCharacterSlot(int charSlot)
	{
		return charSlot <= 0 ? 0 : charSlot - 1;
	}

int FrameCount(int category)
	{
		EnsureLoaded();
		if(accessorySet < 0 || category < 0 || category >= CATEGORY_COUNT) return 0;
		int firstAnim = jjAnimSets[ANIM::CUSTOM[accessorySet]].firstAnim;
		if(firstAnim == 0) return 0;
		int count = jjAnimations[firstAnim + category].frameCount;
		return count > 0 ? count : 0;
	}

	int ClampFrame(int category, int frame)
	{
		if(frame < 0) return NO_ACCESSORY;
		int count = FrameCount(category);
		if(count <= 0) return NO_ACCESSORY;
		if(frame >= count) return count - 1;
		return frame;
	}

	int CharacterSlotForNpcType(int type)
	{
		if(type == npcBunniesCore::NpcAnim::JAZZ) return 1;
		if(type == npcBunniesCore::NpcAnim::SPAZ) return 2;
		if(type == npcBunniesCore::NpcAnim::LORI) return 3;
		if(type == npcBunniesCore::NpcAnim::FROG) return 4;
		if(type == npcBunniesCore::NpcAnim::BIRD) return 5;
		if(type == npcBunniesCore::NpcAnim::DEVAN) return 12;
		if(type == npcBunniesCore::NpcAnim::MARIO) return 8;
		if(type == npcBunniesCore::NpcAnim::SONIC) return 9;
		if(type == npcBunniesCore::NpcAnim::TAILS) return 10;
		if(type == npcBunniesCore::NpcAnim::JJ1) return 11;
		if(type == npcBunniesCore::NpcAnim::YOSHI) return 13;
		if(type == npcBunniesCore::NpcAnim::EVA) return 27;
		if(type == npcBunniesAnimations::getTypeFromTypeName("Jazz")) return 1;
		if(type == npcBunniesAnimations::getTypeFromTypeName("Spaz")) return 2;
		if(type == npcBunniesAnimations::getTypeFromTypeName("Lori")) return 3;
		if(type == npcBunniesCore::NpcAnim::FROG) return 4;
		if(type == npcBunniesCore::NpcAnim::BIRD) return 5;
		if(type == ANIM::DEVILDEVAN) return 12;
		if(type == npcBunniesAnimations::getTypeFromTypeName("Mario")) return 8;
		if(type == npcBunniesAnimations::getTypeFromTypeName("Sonic")) return 9;
		if(type == npcBunniesAnimations::getTypeFromTypeName("Tails")) return 10;
		if(type == npcBunniesAnimations::getTypeFromTypeName("JJ1")) return 11;
		if(type == npcBunniesAnimations::getTypeFromTypeName("Yoshi")) return 13;
		if(type == ANIM::EVA) return 27;
		return 1;
	}

	bool IsNpcCharacterType(int type)
	{
		for(int i = 0; i < int(npcBunniesCore::npcList.length); i++)
		{
			if(type == npcBunniesCore::npcList[i]) return true;
		}
		if(type == npcBunniesAnimations::getTypeFromTypeName("Jazz")) return true;
		if(type == npcBunniesAnimations::getTypeFromTypeName("Spaz")) return true;
		if(type == npcBunniesAnimations::getTypeFromTypeName("Lori")) return true;
		if(type == npcBunniesAnimations::getTypeFromTypeName("Frog")) return true;
		if(type == npcBunniesAnimations::getTypeFromTypeName("Bird")) return true;
		if(type == npcBunniesAnimations::getTypeFromTypeName("Devan")) return true;
		if(type == npcBunniesAnimations::getTypeFromTypeName("Mario")) return true;
		if(type == npcBunniesAnimations::getTypeFromTypeName("Sonic")) return true;
		if(type == npcBunniesAnimations::getTypeFromTypeName("Tails")) return true;
		if(type == npcBunniesAnimations::getTypeFromTypeName("JJ1")) return true;
		if(type == npcBunniesAnimations::getTypeFromTypeName("Yoshi")) return true;
		if(type == npcBunniesAnimations::getTypeFromTypeName("Eva")) return true;
		return false;
	}

	bool CanNpcUseHat(int type)
	{
		return !ENABLE_HAT_ONLY_FOR_NPC_CHARACTERS || IsNpcCharacterType(type);
	}

	int EditorNpcType()
	{
		if(editorTypeIndex < 0) editorTypeIndex = int(npcBunniesCore::npcList.length) - 1;
		if(editorTypeIndex >= int(npcBunniesCore::npcList.length)) editorTypeIndex = 0;
		return npcBunniesCore::npcList[editorTypeIndex];
	}

	string EditorNpcTypeName()
	{
		int type = EditorNpcType();
		if(type == npcBunniesCore::NpcAnim::JAZZ) return "Jazz";
		if(type == npcBunniesCore::NpcAnim::SPAZ) return "Spaz";
		if(type == npcBunniesCore::NpcAnim::LORI) return "Lori";
		if(type == npcBunniesCore::NpcAnim::FROG) return "Frog";
		if(type == npcBunniesCore::NpcAnim::BIRD) return "Bird";
		if(type == npcBunniesCore::NpcAnim::DEVAN) return "Devan";
		if(type == npcBunniesCore::NpcAnim::MARIO) return "Mario";
		if(type == npcBunniesCore::NpcAnim::SONIC) return "Sonic";
		if(type == npcBunniesCore::NpcAnim::TAILS) return "Tails";
		if(type == npcBunniesCore::NpcAnim::JJ1) return "JJ1";
		if(type == npcBunniesCore::NpcAnim::YOSHI) return "Yoshi";
		if(type == npcBunniesCore::NpcAnim::EVA) return "Eva";
		return "NPC";
	}

	int SourceSetForCharacterSlot(int charSlot)
	{
		if(charSlot == 1) return npcBunniesCore::NpcAnim::JAZZ;
		if(charSlot == 2) return npcBunniesCore::NpcAnim::SPAZ;
		if(charSlot == 3) return npcBunniesCore::NpcAnim::LORI;
		if(charSlot == 4) return npcBunniesCore::NpcAnim::FROG;
		if(charSlot == 5) return npcBunniesCore::NpcAnim::BIRD;
		if(charSlot == 8) return npcBunniesCore::NpcAnim::MARIO;
		if(charSlot == 9) return npcBunniesCore::NpcAnim::SONIC;
		if(charSlot == 10) return npcBunniesCore::NpcAnim::TAILS;
		if(charSlot == 11) return npcBunniesCore::NpcAnim::JJ1;
		if(charSlot == 12) return 7;
		if(charSlot == 13) return npcBunniesCore::NpcAnim::YOSHI;
		return -1;
	}

	int BaseAnimSetForCharacterSlot(int charSlot)
	{
		if(charSlot == 1) return ANIM::JAZZ;
		if(charSlot == 2) return ANIM::SPAZ;
		if(charSlot == 3) return ANIM::LORI;
		if(charSlot == 4) return ANIM::FROG;
		if(charSlot == 5) return ANIM::BIRD;
		return -1;
	}

	int FrameIndexForAnim(int animSet, int anim, int frame)
	{
		if(animSet < 0 || jjAnimSets[animSet].firstAnim == 0) return 0;
		int firstAnim = jjAnimSets[animSet].firstAnim;
		int animIndex = firstAnim + anim;
		if(animIndex < 0) return 0;
		int count = jjAnimations[animIndex].frameCount;
		int remappedFrame = count > 0 ? frame % count : 0;
		if(remappedFrame < 0) remappedFrame += count;
		int firstFrame = jjAnimations[firstAnim].firstFrame;
		int currentFrame = jjAnimations[animIndex].firstFrame + remappedFrame;
		int index = currentFrame - firstFrame;
		if(index < 0) index = 0;
		return index;
	}

	int LocalFrameForAnim(int animSet, int anim, int frame)
	{
		if(animSet < 0 || jjAnimSets[animSet].firstAnim == 0) return frame < 0 ? 0 : frame;
		int animIndex = jjAnimSets[animSet].firstAnim + anim;
		int count = jjAnimations[animIndex].frameCount;
		if(count <= 0) return frame < 0 ? 0 : frame;
		int remappedFrame = frame % count;
		if(remappedFrame < 0) remappedFrame += count;
		return remappedFrame;
	}

	int FrameIndexForRabbitAnim(int charSlot, int rabbitAnim, int frame)
	{
		int sourceSet = SourceSetForCharacterSlot(charSlot);
		if(sourceSet >= 0 && sourceSet < int(minAnimStart.length))
		{
			if(rabbitAnim < 0 || rabbitAnim >= int(minAnimStart[sourceSet].length)) return -1;
			int start = minAnimStart[sourceSet][rabbitAnim];
			int count = minAnimCount[sourceSet][rabbitAnim];
			if(start < 0 || count <= 0) return -1;
			int remappedFrame = frame % count;
			if(remappedFrame < 0) remappedFrame += count;
			return start + remappedFrame;
		}

		int baseAnimSet = BaseAnimSetForCharacterSlot(charSlot);
		if(baseAnimSet < 0 || jjAnimSets[baseAnimSet].firstAnim == 0 || rabbitAnim < 0) return -1;
		int firstAnim = jjAnimSets[baseAnimSet].firstAnim;
		int animIndex = firstAnim + rabbitAnim;
		int count = jjAnimations[animIndex].frameCount;
		int remappedFrame = count > 0 ? frame % count : 0;
		if(remappedFrame < 0) remappedFrame += count;
		return jjAnimations[animIndex].firstFrame + remappedFrame - jjAnimations[firstAnim].firstFrame;
	}

	bool HasCharactersModAnim(int sourceSet, int anim)
	{
		return sourceSet >= 0
			&& sourceSet < int(npcBunniesAnimations::charactersModAnimStart.length)
			&& anim >= 0
			&& anim < int(npcBunniesAnimations::charactersModAnimStart[sourceSet].length)
			&& anim < int(npcBunniesAnimations::charactersModAnimCount[sourceSet].length)
			&& npcBunniesAnimations::charactersModAnimStart[sourceSet][anim] >= 0
			&& npcBunniesAnimations::charactersModAnimCount[sourceSet][anim] > 0;
	}

	int CharactersModSourceSetForNpcType(int npcType, int anim)
	{
		if(HasCharactersModAnim(npcType, anim)) return npcType;
		int charSlot = CharacterSlotForNpcType(npcType);
		int sourceSet = SourceSetForCharacterSlot(charSlot);
		if(HasCharactersModAnim(sourceSet, anim)) return sourceSet;
		return -1;
	}

	int NpcFrameIndexForState(int npcType, int state, int frame)
	{
		if(state < 0) state = 0;
		if(state >= int(stateNpcAnims.length)) state = int(stateNpcAnims.length) - 1;
		if(npcType == npcBunniesCore::NpcAnim::EVA || npcType == ANIM::EVA)
		{
			if(state == 3 || state == 4) return LocalFrameForAnim(ANIM::FLAG, 6, frame);
			return frame < 0 ? 0 : frame;
		}
		if((npcType == npcBunniesCore::NpcAnim::DEVAN || npcType == ANIM::DEVILDEVAN) && state == 5)
		{
			if(jjAnimSets[ANIM::DEVILDEVAN].firstAnim == 0) jjAnimSets[ANIM::DEVILDEVAN].load();
			return FrameIndexForAnim(ANIM::DEVILDEVAN, 14, frame);
		}
		if(npcType == npcBunniesCore::NpcAnim::DEVAN || npcType == ANIM::DEVILDEVAN)
		{
			int actualAnim = ActualNpcAnimForEditorState(npcType, state);
			if(actualAnim < 0) actualAnim = 0;
			int devSourceSet = CharactersModSourceSetForNpcType(npcType, actualAnim);
			if(devSourceSet >= 0)
			{
				int devStart = npcBunniesAnimations::charactersModAnimStart[devSourceSet][actualAnim];
				int devCount = SpecialEditorPreviewFrameCount(npcType, state);
				if(devCount <= 0) devCount = npcBunniesAnimations::charactersModAnimCount[devSourceSet][actualAnim];
				int devRemappedFrame = devCount > 0 ? frame % devCount : 0;
				if(devRemappedFrame < 0) devRemappedFrame += devCount;
				return devStart + devRemappedFrame;
			}
		}
		int npcAnim = ActualNpcAnimForEditorState(npcType, state);
		int sourceSet = CharactersModSourceSetForNpcType(npcType, npcAnim);
		if(sourceSet >= 0)
		{
			int start = npcBunniesAnimations::charactersModAnimStart[sourceSet][npcAnim];
			int count = npcBunniesAnimations::charactersModAnimCount[sourceSet][npcAnim];
			int division = GetAnimationDivisionForNpcType(npcType);
			if(division < 1) division = 1;
			int editableFrameCount = count / division;
			if(editableFrameCount <= 0) editableFrameCount = count;
			int remappedFrame = frame % editableFrameCount;
			if(remappedFrame < 0) remappedFrame += editableFrameCount;
			return start + remappedFrame;
		}
		int charSlot = CharacterSlotForNpcType(npcType);
		return FrameIndexForRabbitAnim(charSlot, stateRabbitAnims[state], frame);
	}

	int FirstNpcFrameIndexForCoordinateLookup(int npcType)
	{
		// Do NOT use the minimum start across all states here. Some NPC tables can
		// contain unrelated/unused states at lower frame indexes, which made runtime
		// keep using packed frames like 170-199 for hat coordinates. The coordinate
		// .asdat uses frames relative to the character's first editable animation,
		// normally Idle: idle 0, walk 5, walk2 6, etc.
		int idleAnim = stateNpcAnims[0];
		int sourceSet = CharactersModSourceSetForNpcType(npcType, idleAnim);
		if(sourceSet >= 0)
		{
			int idleStart = npcBunniesAnimations::charactersModAnimStart[sourceSet][idleAnim];
			int idleCount = npcBunniesAnimations::charactersModAnimCount[sourceSet][idleAnim];
			if(idleStart >= 0 && idleCount > 0) return idleStart;
		}

		for(int i = 0; i < int(stateNpcAnims.length); i++)
		{
			int npcAnim = stateNpcAnims[i];
			sourceSet = CharactersModSourceSetForNpcType(npcType, npcAnim);
			if(sourceSet < 0) continue;
			int start = npcBunniesAnimations::charactersModAnimStart[sourceSet][npcAnim];
			int count = npcBunniesAnimations::charactersModAnimCount[sourceSet][npcAnim];
			if(start >= 0 && count > 0) return start;
		}

		return -1;
	}

	int CoordinateFrameIndexForNpcState(int npcType, int state, int frame)
	{
		// Coordinates in npcHats.asdat are character-relative frame numbers:
		// Jazz idle starts at 0, walk starts at 5, etc.
		// Custom NPC body frames can be packed much later in the global frame list
		// (for example 170+), so subtract that NPC's first packed frame before
		// using the value as a coordinate key.
		if(state < 0) state = 0;
		if(state >= int(stateRabbitAnims.length)) state = int(stateRabbitAnims.length) - 1;

		int physical = NpcFrameIndexForState(npcType, state, frame);
		int first = FirstNpcFrameIndexForCoordinateLookup(npcType);
		if(first >= 0 && physical >= first) return physical - first;

		int charSlot = CharacterSlotForNpcType(npcType);
		int index = FrameIndexForRabbitAnim(charSlot, stateRabbitAnims[state], frame);
		if(index >= 0) return index;

		int fallbackSlot = DefaultCharacterSlotForCustom(charSlot);
		if(fallbackSlot != charSlot)
		{
			index = FrameIndexForRabbitAnim(fallbackSlot, stateRabbitAnims[state], frame);
			if(index >= 0) return index;
		}

		// Last resort: use the local animation frame supplied by the caller. Never
		// return the packed 170+ body frame as an accessory coordinate key.
		return frame < 0 ? 0 : frame;
	}

	int CompactFrameIndexForBaseCharacterAnim(int npcType, int animSet, int anim, int physicalFrame)
	{
		int charSlot = CharacterSlotForNpcType(npcType);
		if(animSet != BaseAnimSetForCharacterSlot(charSlot)) return -1;
		if(animSet < 0 || jjAnimSets[animSet].firstAnim == 0 || anim < 0) return -1;
		int sourceSet = CharactersModSourceSetForNpcType(npcType, anim);
		if(sourceSet < 0) return -1;

		int compactStart = npcBunniesAnimations::charactersModAnimStart[sourceSet][anim];
		int compactCount = npcBunniesAnimations::charactersModAnimCount[sourceSet][anim];
		if(compactStart < 0 || compactCount <= 0) return -1;

		int firstAnim = jjAnimSets[animSet].firstAnim;
		int animIndex = firstAnim + anim;
		int baseStart = jjAnimations[animIndex].firstFrame - jjAnimations[firstAnim].firstFrame;
		int baseCount = jjAnimations[animIndex].frameCount;
		if(baseCount <= 0) return compactStart;

		int rel = physicalFrame - baseStart;
		rel = rel % baseCount;
		if(rel < 0) rel += baseCount;

		int division = GetAnimationDivisionForCharacterSlot(charSlot);
		if(division < 1) division = 1;
		int editableFrameCount = compactCount / division;
		if(editableFrameCount <= 0) editableFrameCount = compactCount;
		rel = rel % editableFrameCount;
		if(rel < 0) rel += editableFrameCount;
		return compactStart + rel;
	}

	int CoordinateFrameIndexForNpcAnimDraw(int npcType, int animSet, int anim, int frame)
	{
		// Same coordinate-key fix as the editor path, but for the older Draw(...)
		// entry point. Some NPC draw calls pass a packed/custom frame here too.
		int physical = FrameIndexForAnim(animSet, anim, frame);
		int compact = CompactFrameIndexForBaseCharacterAnim(npcType, animSet, anim, physical);
		if(compact >= 0) return compact;
		int first = FirstNpcFrameIndexForCoordinateLookup(npcType);
		if(first >= 0 && physical >= first) return physical - first;
		return physical >= 0 && physical < framesPerCharacter ? physical : (frame < 0 ? 0 : frame);
	}

	int CurrentEditorFrameIndex()
	{
		return NpcFrameIndexForState(EditorNpcType(), editorState, editorFrame);
	}

	int StoredAdjustmentFrameFromSegmentForSlot(int charSlot, int start, int count, int physicalFrame)
	{
		if(start < 0 || count <= 0) return physicalFrame;
		if(physicalFrame < start || physicalFrame >= start + count) return physicalFrame;

		int division = GetAnimationDivisionForCharacterSlot(charSlot);
		if(division <= 1) return physicalFrame;

		// Saved .asdat frame numbers stay physical/full-color indexes:
		// Jazz idle uses 0, Jazz walk starts at 5, etc. Runtime color-layer
		// frames collapse back to that saved full-color frame inside the same
		// animation segment.
		int editableFrameCount = (count + division - 1) / division;
		if(editableFrameCount <= 0) return physicalFrame;

		int rel = physicalFrame - start;
		int baseRel = rel % editableFrameCount;
		if(baseRel < 0) baseRel += editableFrameCount;
		return start + baseRel;
	}

	int StoredAdjustmentFrameFromSegment(int npcType, int start, int count, int physicalFrame)
	{
		return StoredAdjustmentFrameFromSegmentForSlot(CharacterSlotForNpcType(npcType), start, count, physicalFrame);
	}

	int StoredAdjustmentFrameForNpcState(int npcType, int state, int physicalFrame)
	{
		if(state < 0) state = 0;
		if(state >= int(stateNpcAnims.length)) state = int(stateNpcAnims.length) - 1;
		if(npcType == npcBunniesCore::NpcAnim::EVA || npcType == ANIM::EVA)
			return physicalFrame;
		if(npcType == npcBunniesCore::NpcAnim::DEVAN || npcType == ANIM::DEVILDEVAN)
			return physicalFrame;
		int npcAnim = ActualNpcAnimForEditorState(npcType, state);

		int sourceSet = CharactersModSourceSetForNpcType(npcType, npcAnim);
		if(sourceSet >= 0)
		{
			int start = npcBunniesAnimations::charactersModAnimStart[sourceSet][npcAnim];
			int count = npcBunniesAnimations::charactersModAnimCount[sourceSet][npcAnim];
			return StoredAdjustmentFrameFromSegment(npcType, start, count, physicalFrame);
		}

		int charSlot = CharacterSlotForNpcType(npcType);
		int rabbitAnim = stateRabbitAnims[state];
		int fallbackSourceSet = SourceSetForCharacterSlot(charSlot);
		if(fallbackSourceSet >= 0 && fallbackSourceSet < int(minAnimStart.length)
		&& rabbitAnim >= 0 && rabbitAnim < int(minAnimStart[fallbackSourceSet].length))
		{
			int start = minAnimStart[fallbackSourceSet][rabbitAnim];
			int count = minAnimCount[fallbackSourceSet][rabbitAnim];
			if(start >= 0 && count > 0)
				return StoredAdjustmentFrameFromSegment(npcType, start, count, physicalFrame);
		}

		int baseAnimSet = BaseAnimSetForCharacterSlot(charSlot);
		if(baseAnimSet >= 0 && jjAnimSets[baseAnimSet].firstAnim > 0 && rabbitAnim >= 0)
		{
			int firstAnim = jjAnimSets[baseAnimSet].firstAnim;
			int animIndex = firstAnim + rabbitAnim;
			int start = jjAnimations[animIndex].firstFrame - jjAnimations[firstAnim].firstFrame;
			int count = jjAnimations[animIndex].frameCount;
			return StoredAdjustmentFrameFromSegment(npcType, start, count, physicalFrame);
		}

		return physicalFrame;
	}

	int StoredAdjustmentFrameForAnim(int npcType, int animSet, int anim, int physicalFrame)
	{
		if(animSet < 0 || jjAnimSets[animSet].firstAnim == 0 || anim < 0) return physicalFrame;
		int compact = CompactFrameIndexForBaseCharacterAnim(npcType, animSet, anim, physicalFrame);
		if(compact >= 0) return compact;
		int firstAnim = jjAnimSets[animSet].firstAnim;
		int animIndex = firstAnim + anim;
		int start = jjAnimations[animIndex].firstFrame - jjAnimations[firstAnim].firstFrame;
		int count = jjAnimations[animIndex].frameCount;
		return StoredAdjustmentFrameFromSegment(npcType, start, count, physicalFrame);
	}

	int StoredAdjustmentFrameForCharacterSlotNpcState(int charSlot, int state, int physicalFrame)
	{
		if(state < 0) state = 0;
		if(state >= int(stateRabbitAnims.length)) state = int(stateRabbitAnims.length) - 1;
		int rabbitAnim = stateRabbitAnims[state];

		int sourceSet = SourceSetForCharacterSlot(charSlot);
		if(sourceSet >= 0 && sourceSet < int(minAnimStart.length)
		&& rabbitAnim >= 0 && rabbitAnim < int(minAnimStart[sourceSet].length))
		{
			int start = minAnimStart[sourceSet][rabbitAnim];
			int count = minAnimCount[sourceSet][rabbitAnim];
			if(start >= 0 && count > 0)
				return StoredAdjustmentFrameFromSegmentForSlot(charSlot, start, count, physicalFrame);
		}

		int baseAnimSet = BaseAnimSetForCharacterSlot(charSlot);
		if(baseAnimSet >= 0 && jjAnimSets[baseAnimSet].firstAnim > 0 && rabbitAnim >= 0)
		{
			int firstAnim = jjAnimSets[baseAnimSet].firstAnim;
			int animIndex = firstAnim + rabbitAnim;
			int start = jjAnimations[animIndex].firstFrame - jjAnimations[firstAnim].firstFrame;
			int count = jjAnimations[animIndex].frameCount;
			return StoredAdjustmentFrameFromSegmentForSlot(charSlot, start, count, physicalFrame);
		}

		return physicalFrame;
	}

	int StoredAdjustmentFrameForCharacterSlotAnim(int charSlot, int animSet, int anim, int physicalFrame)
	{
		if(animSet < 0 || jjAnimSets[animSet].firstAnim == 0 || anim < 0) return physicalFrame;
		int firstAnim = jjAnimSets[animSet].firstAnim;
		int animIndex = firstAnim + anim;
		int start = jjAnimations[animIndex].firstFrame - jjAnimations[firstAnim].firstFrame;
		int count = jjAnimations[animIndex].frameCount;
		return StoredAdjustmentFrameFromSegmentForSlot(charSlot, start, count, physicalFrame);
	}

	int RabbitAnimForNpcState(int movementAnim, bool shooting, bool jumping, bool moving)
	{
		if(shooting)
		{
			if(jumping || movementAnim == RABBIT::JUMPING1 || movementAnim == RABBIT::FALL) return RABBIT::JUMPFIRERIGHT;
			if(movementAnim == RABBIT::RUN1 || movementAnim == RABBIT::RUN3 || moving) return RUN_SHOOT_RABBIT_ANIM;
			return RABBIT::FIRE;
		}
		if(movementAnim == RABBIT::RUN1) return RABBIT::RUN1;
		if(movementAnim == RABBIT::RUN3) return RABBIT::RUN3;
		if(movementAnim == RABBIT::FALL) return RABBIT::FALL;
		if(movementAnim == RABBIT::JUMPING1) return RABBIT::JUMPING1;
		return RABBIT::IDLE1;
	}

	int NpcStateForMovement(int movementAnim, bool shooting, bool jumping, bool moving)
	{
		if(shooting)
		{
			if(jumping || movementAnim == RABBIT::JUMPING1 || movementAnim == RABBIT::FALL) return 5;
			if(movementAnim == RABBIT::RUN1 || movementAnim == RABBIT::RUN3 || moving) return 6;
			return 5;
		}
		if(movementAnim == RABBIT::RUN1) return 1;
		if(movementAnim == RABBIT::RUN3) return 2;
		if(movementAnim == RABBIT::FALL) return 3;
		if(movementAnim == RABBIT::JUMPING1) return 4;
		return 0;
	}

	int NpcStateForActualAnim(int anim, int fallbackState)
	{
		for(int i = 0; i < int(stateNpcAnims.length); i++)
		{
			if(stateNpcAnims[i] == anim) return i;
		}
		return fallbackState;
	}

	int EditorNpcTypeIndex(int npcType)
	{
		if(npcType >= npcBunniesCore::NpcAnim::JAZZ && npcType <= npcBunniesCore::NpcAnim::EVA) return npcType;
		return int(npcBunniesAnimations::animTypes.find(npcType));
	}

	int ActualNpcAnimForEditorState(int npcType, int state)
	{
		if(state < 0) state = 0;
		if(state >= int(stateNpcAnims.length)) state = int(stateNpcAnims.length) - 1;

		int npcTypeIndex = EditorNpcTypeIndex(npcType);
		if(npcTypeIndex == npcBunniesCore::NpcAnim::FROG && state == 3) return 4;
		if(state == 5)
		{
			if(npcTypeIndex == npcBunniesCore::NpcAnim::FROG) return 3;
			if(npcBunniesCore::NpcHasStandingFightingAnimation(npcTypeIndex)) return 5;
			return 0;
		}
		if(state == 6)
		{
			if(npcBunniesCore::NpcHasJumpFightingAnimation(npcTypeIndex)) return 6;
			if(npcBunniesCore::NpcHasStandingFightingAnimation(npcTypeIndex)) return 5;
			return 1;
		}

		return stateNpcAnims[state];
	}


	int DefaultCharacterSlotForCustom(int charSlot)
	{
		if(charSlot == 1 || charSlot == 11) return 1;
		if(charSlot == 3) return 3;
		if(charSlot == 4) return 4;
		if(charSlot == 5 || charSlot == 6) return charSlot;
		return 2;
	}

	int EditedIndexOrMissing(int category, int charSlot, int frameIndex)
	{
		int index = ExistingIndex(category, charSlot, frameIndex);
		if(index >= 0 && edited[category][index]) return index;
		return -1;
	}

	int ClosestEditedIndexOrMissing(int category, int charSlot, int frameIndex)
	{
		if(category < 0 || category >= CATEGORY_COUNT) return -1;
		if(charSlot < 0 || charSlot >= characterSlotCount) return -1;
		if(framesPerCharacter <= 0) return -1;
		if(frameIndex < 0) frameIndex = 0;
		if(frameIndex >= framesPerCharacter) frameIndex = framesPerCharacter - 1;
		EnsureAccessoryLookupCache();
		int index = charSlot * framesPerCharacter + frameIndex;
		if(category >= int(nearestEditedIndex.length) || index < 0 || index >= int(nearestEditedIndex[category].length)) return -1;
		return nearestEditedIndex[category][index];
	}

	int DrawAdjustmentIndex(int category, int storageSlot, int frameIndex, int storedFrameIndex, int fallbackStorageSlot = -1, int fallbackFrameIndex = -1, int fallbackStoredFrameIndex = -1)
	{
		// First use the saved full-color frame for this animation segment.
		// Runtime draws the NPC body in color layers, but accessories are saved
		// once on the full frame: 5, not 13/21/29/37 for a 5-layer walk segment.
		int index = EditedIndexOrMissing(category, storageSlot, storedFrameIndex);
		if(index >= 0) return index;

		// Then use coordinates saved for this exact NPC slot/frame if it is a
		// real non-layered frame or intentionally has its own adjustment.
		if(storedFrameIndex != frameIndex)
		{
			index = EditedIndexOrMissing(category, storageSlot, frameIndex);
			if(index >= 0) return index;
		}

		// If the exact frame math is wrong for a custom NPC, do not hide the
		// accessory. Use the nearest coordinate that was actually saved for the
		// same character slot. This keeps real .asdat coordinates and avoids
		// the bad fake 0,0 fallback.
		index = ClosestEditedIndexOrMissing(category, storageSlot, storedFrameIndex);
		if(index >= 0) return index;

		// Custom/NPC slots need the base character's coordinate frame, not the
		// custom sprite frame. This is the xmasAccessories-style fallback, but
		// still requires a real saved coordinate.
		if(fallbackStorageSlot >= 0 && fallbackStorageSlot != storageSlot)
		{
			if(fallbackFrameIndex < 0) fallbackFrameIndex = frameIndex;
			if(fallbackStoredFrameIndex < 0) fallbackStoredFrameIndex = storedFrameIndex;

			index = EditedIndexOrMissing(category, fallbackStorageSlot, fallbackStoredFrameIndex);
			if(index >= 0) return index;

			if(fallbackStoredFrameIndex != fallbackFrameIndex)
			{
				index = EditedIndexOrMissing(category, fallbackStorageSlot, fallbackFrameIndex);
				if(index >= 0) return index;
			}

			index = ClosestEditedIndexOrMissing(category, fallbackStorageSlot, fallbackStoredFrameIndex);
			if(index >= 0) return index;
		}

		return -1;
	}

	void DrawAccessorySprite(int category, float x, float y, int direction, int grav, float baseScale, int accessoryFrame, int index, int coordinateFrameIndex, int storedFrameIndex, int physicalFrameIndex = -1, int state = -1)
	{
		float drawX = x + adjustX[category][index] * direction * baseScale;
		float drawY = y + adjustY[category][index] * grav * baseScale;

		jjDrawRotatedSprite(
			drawX,
			drawY,
			ANIM::CUSTOM[accessorySet],
			category,
			accessoryFrame,
			rotation[category][index] * direction * grav,
			scaleX[category][index] * direction * baseScale,
			scaleY[category][index] * grav * baseScale,
			SPRITE::NORMAL,
			0, 4, 4, -1
		);

		if(category == CATEGORY_HAT && NPC_DEBUG_HAT_POSITION_ON)
		{
			jjDrawString(
				int(drawX),
				int(drawY) - 50,
				//"anim=" + ANIM::CUSTOM[accessorySet]
				//+ " category=" + category +
				"index="+index +
				" coord=" + coordinateFrameIndex +
				" stored=" + storedFrameIndex +
				" phys=" + physicalFrameIndex +
				" state=" + state +
				" frame=" + accessoryFrame 
				+ " pos=" + adjustX[category][index] + "," 
					+ adjustY[category][index] 
				+ " rot=" + rotation[category][index],
				STRING::SMALL,
				STRING::NORMAL,
				0, 4, 4, -1
			);
		}
	}

	int NpcAccessoryFrame(npcBunniesCore::npcBunny &in npc, int category)
	{
		if(category == CATEGORY_HAT) return npc.hatFrame;
		if(category == CATEGORY_SCARF) return npc.scarfFrame;
		return npc.glassesFrame;
	}

	bool HasDrawableAccessory(npcBunniesCore::npcBunny &in npc)
	{
		if(npc.scarfFrame >= 0 || npc.glassesFrame >= 0) return true;
		if(npc.hatFrame < 0) return false;
		return CanNpcUseHat(npc.type);
	}

	void Draw(npcBunniesCore::npcBunny &in npc, float x, float y, int animSet, int anim, int frame, int direction, int grav = 1, float baseScale = 1.0)
	{
		if(!npcBunniesCore::ENABLE_NPC_ACCESSORIES) return;
		if(!NpcHasDrawableAccessory(npc)) return;
		EnsureLoaded();
		EnsureAccessoryLookupCache();
		if(accessorySet < 0 || jjAnimSets[ANIM::CUSTOM[accessorySet]].firstAnim == 0) return;
		int charSlot = CharacterSlotForNpcType(npc.type);
		int storageSlot = StorageSlotForCharacterSlot(charSlot);
		int physicalFrameIndex = FrameIndexForAnim(animSet, anim, frame);
		int frameIndex = CoordinateFrameIndexForNpcAnimDraw(npc.type, animSet, anim, frame);
		int storedFrameIndex = StoredAdjustmentFrameForAnim(npc.type, animSet, anim, physicalFrameIndex);
		int fallbackFrameIndex = frameIndex;
		int fallbackStoredFrameIndex = storedFrameIndex;
		int fallbackSlot = DefaultCharacterSlotForCustom(charSlot);
		int fallbackStorageSlot = StorageSlotForCharacterSlot(fallbackSlot);
		if(fallbackSlot != charSlot)
		{
			int fallbackAnimSet = BaseAnimSetForCharacterSlot(fallbackSlot);
			if(fallbackAnimSet >= 0 && jjAnimSets[fallbackAnimSet].firstAnim > 0)
				fallbackFrameIndex = FrameIndexForAnim(fallbackAnimSet, anim, frame);
			fallbackStoredFrameIndex = StoredAdjustmentFrameForCharacterSlotAnim(fallbackSlot, fallbackAnimSet, anim, fallbackFrameIndex);
		}
		for(int category = 0; category < CATEGORY_COUNT; category++)
		{
			if(category == CATEGORY_HAT && !CanNpcUseHat(npc.type)) continue;
			int accessoryFrame = NpcAccessoryFrame(npc, category);
			if(accessoryFrame < 0) continue;
			accessoryFrame = ClampFrame(category, accessoryFrame);
			if(accessoryFrame < 0) continue;
			int index = DrawAdjustmentIndex(category, storageSlot, frameIndex, storedFrameIndex, fallbackStorageSlot, fallbackFrameIndex, fallbackStoredFrameIndex);
			if(index < 0) continue;
			DrawAccessorySprite(category, x, y, direction, grav, baseScale, accessoryFrame, index, frameIndex, storedFrameIndex, physicalFrameIndex, -1);
		}
	}

	void DrawForNpcState(npcBunniesCore::npcBunny &in npc, float x, float y, int fallbackAnimSet, int fallbackAnim, int frame, int movementAnim, bool shooting, bool jumping, bool moving, int direction, int grav = 1, float baseScale = 1.0)
	{
		if(!npcBunniesCore::ENABLE_NPC_ACCESSORIES) return;
		if(!NpcHasDrawableAccessory(npc)) return;
		EnsureLoaded();
		EnsureAccessoryLookupCache();
		if(accessorySet < 0 || jjAnimSets[ANIM::CUSTOM[accessorySet]].firstAnim == 0) return;
		int charSlot = CharacterSlotForNpcType(npc.type);
		int movementState = NpcStateForMovement(movementAnim, shooting, jumping, moving);
		int state = (npc.type == npcBunniesCore::NpcAnim::FROG || npc.type == npcBunniesCore::NpcAnim::DEVAN || npc.type == ANIM::DEVILDEVAN) ? movementState : NpcStateForActualAnim(fallbackAnim, movementState);
		if((npc.type == npcBunniesCore::NpcAnim::EVA || npc.type == ANIM::EVA) && (movementState == 3 || movementState == 4)) state = movementState;
		int stateFrameCount = StateFrameCountForNpc(npc, state);
		if(stateFrameCount > 0)
		{
			frame = frame % stateFrameCount;
			if(frame < 0) frame += stateFrameCount;
		}
		if(frame < 0) frame = 0;
		if(frame >= framesPerCharacter) frame = framesPerCharacter - 1;
		for(int category = 0; category < CATEGORY_COUNT; category++)
		{
			if(category == CATEGORY_HAT && !CanNpcUseHat(npc.type)) continue;
			int accessoryFrame = NpcAccessoryFrame(npc, category);
			if(accessoryFrame < 0) continue;
			accessoryFrame = ClampFrame(category, accessoryFrame);
			if(accessoryFrame < 0) continue;

			int cacheIndex = LazyAccessoryCacheIndex(npc.id, state, frame, category);
			if(cacheIndex >= 0
			&& lazyAccessoryLookupGeneration[cacheIndex] == accessoryLookupGeneration
			&& lazyAccessoryNpcType[cacheIndex] == npc.type
			&& lazyAccessoryCategoryFrame[cacheIndex] == accessoryFrame)
			{
				if(lazyAccessoryIndex[cacheIndex] >= 0)
					DrawAccessorySprite(category, x, y, direction, grav, baseScale, accessoryFrame, lazyAccessoryIndex[cacheIndex], lazyAccessoryCoordinateFrameIndex[cacheIndex], lazyAccessoryStoredFrameIndex[cacheIndex], lazyAccessoryPhysicalFrameIndex[cacheIndex], state);
				continue;
			}

			int storageSlot = StorageSlotForCharacterSlot(charSlot);
			int physicalFrameIndex = NpcFrameIndexForState(npc.type, state, frame);
			if(physicalFrameIndex < 0) physicalFrameIndex = FrameIndexForAnim(fallbackAnimSet, fallbackAnim, frame);
			int frameIndex = CoordinateFrameIndexForNpcState(npc.type, state, frame);
			if(frameIndex < 0) frameIndex = physicalFrameIndex;
			int storedFrameIndex = StoredAdjustmentFrameForNpcState(npc.type, state, physicalFrameIndex);
			int fallbackFrameIndex = frameIndex;
			int fallbackStoredFrameIndex = storedFrameIndex;
			int fallbackSlot = DefaultCharacterSlotForCustom(charSlot);
			int fallbackStorageSlot = StorageSlotForCharacterSlot(fallbackSlot);
			if(fallbackSlot != charSlot)
			{
				fallbackFrameIndex = FrameIndexForRabbitAnim(fallbackSlot, stateRabbitAnims[state], frame);
				if(fallbackFrameIndex < 0) fallbackFrameIndex = frameIndex;
				fallbackStoredFrameIndex = StoredAdjustmentFrameForCharacterSlotNpcState(fallbackSlot, state, fallbackFrameIndex);
			}
			int index = DrawAdjustmentIndex(category, storageSlot, frameIndex, storedFrameIndex, fallbackStorageSlot, fallbackFrameIndex, fallbackStoredFrameIndex);
			if(cacheIndex >= 0)
			{
				lazyAccessoryLookupGeneration[cacheIndex] = accessoryLookupGeneration;
				lazyAccessoryNpcType[cacheIndex] = npc.type;
				lazyAccessoryCategoryFrame[cacheIndex] = accessoryFrame;
				lazyAccessoryIndex[cacheIndex] = index;
				lazyAccessoryCoordinateFrameIndex[cacheIndex] = frameIndex;
				lazyAccessoryStoredFrameIndex[cacheIndex] = storedFrameIndex;
				lazyAccessoryPhysicalFrameIndex[cacheIndex] = physicalFrameIndex;
			}
			if(index < 0) continue;
			DrawAccessorySprite(category, x, y, direction, grav, baseScale, accessoryFrame, index, frameIndex, storedFrameIndex, physicalFrameIndex, state);
		}
	}

	void Load()
	{
		if(!npcBunniesCore::ENABLE_NPC_ACCESSORIES) return;
		EnsureLoaded();
	}

	int GetBaseFrameForStoredAdjustment(int npcType, int state, int physicalFrame)
	{
		// Convert physical frame back to base frame (layer 0) for lookup
		int npcAnim = ActualNpcAnimForEditorState(npcType, state);
		int division = GetAnimationDivisionForNpcType(npcType);
		int count = 1;
		int sourceSet = CharactersModSourceSetForNpcType(npcType, npcAnim);
		if(sourceSet >= 0)
		{
			count = npcBunniesAnimations::charactersModAnimCount[sourceSet][npcAnim];
		}
		int layerFrameCount = count / division;
		if(layerFrameCount <= 0) return physicalFrame;
		return physicalFrame % layerFrameCount;
	}

	void LoadEditorAdjustment()
	{
		EnsureLoaded();
		int npcType = EditorNpcType();
		int physicalFrameIndex = NpcFrameIndexForState(npcType, editorState, editorFrame);
		int storedFrameIndex = StoredAdjustmentFrameForNpcState(npcType, editorState, physicalFrameIndex);
		int index = ExistingIndex(editorCategory, StorageSlotForCharacterSlot(CharacterSlotForNpcType(npcType)), storedFrameIndex);
		if(index < 0 || !edited[editorCategory][index])
		{
			editorX = 0;
			editorY = 0;
			editorRotation = 0;
			editorScaleX = 1.0;
			editorScaleY = 1.0;
			return;
		}
		editorX = adjustX[editorCategory][index];
		editorY = adjustY[editorCategory][index];
		editorRotation = rotation[editorCategory][index];
		editorScaleX = scaleX[editorCategory][index];
		editorScaleY = scaleY[editorCategory][index];
	}

	void SaveEditorAdjustment()
	{
		EnsureLoaded();
		int npcType = EditorNpcType();
		int physicalFrameIndex = NpcFrameIndexForState(npcType, editorState, editorFrame);
		int storedFrameIndex = StoredAdjustmentFrameForNpcState(npcType, editorState, physicalFrameIndex);
		int index = Index(editorCategory, StorageSlotForCharacterSlot(CharacterSlotForNpcType(npcType)), storedFrameIndex);
		adjustX[editorCategory][index] = editorX;
		adjustY[editorCategory][index] = editorY;
		rotation[editorCategory][index] = editorRotation;
		scaleX[editorCategory][index] = editorScaleX;
		scaleY[editorCategory][index] = editorScaleY;
		edited[editorCategory][index] = true;
		accessoryLookupDirty = true;
		SaveCategory(editorCategory);
	}

	int PreviousEditorPhysicalFrameIndex(int npcType)
	{
		if(editorFrame > 0)
		{
			int previousPhysicalFrame = NpcFrameIndexForState(npcType, editorState, editorFrame - 1);
			return StoredAdjustmentFrameForNpcState(npcType, editorState, previousPhysicalFrame);
		}

		if(editorState <= 0) return -1;
		int previousState = editorState - 1;
		int previousStateFrameCount = LogicalFrameCountForState(npcType, previousState);
		if(previousStateFrameCount <= 0) return -1;
		int previousPhysicalFrame = NpcFrameIndexForState(npcType, previousState, previousStateFrameCount - 1);
		return StoredAdjustmentFrameForNpcState(npcType, previousState, previousPhysicalFrame);
	}

	void CopyPreviousEditorFrameAdjustment()
	{
		EnsureLoaded();
		ClampEditor();
		int npcType = EditorNpcType();
		int previousFrameIndex = PreviousEditorPhysicalFrameIndex(npcType);
		if(previousFrameIndex < 0) return;
		int previousIndex = ExistingIndex(editorCategory, StorageSlotForCharacterSlot(CharacterSlotForNpcType(npcType)), previousFrameIndex);
		if(previousIndex < 0 || !edited[editorCategory][previousIndex]) return;
		editorX = adjustX[editorCategory][previousIndex];
		editorY = adjustY[editorCategory][previousIndex];
		editorRotation = rotation[editorCategory][previousIndex];
		editorScaleX = scaleX[editorCategory][previousIndex];
		editorScaleY = scaleY[editorCategory][previousIndex];
		SaveEditorAdjustment();
	}

	int LogicalFrameCountForState(int npcType, int state)
	{
		if(state < 0) state = 0;
		if(state >= int(stateNpcAnims.length)) state = int(stateNpcAnims.length) - 1;
		int npcAnim = ActualNpcAnimForEditorState(npcType, state);
		int division = GetAnimationDivisionForNpcType(npcType);
		if(division < 1) division = 1;
		int sourceSet = CharactersModSourceSetForNpcType(npcType, npcAnim);
		if(sourceSet >= 0)
		{
			int count = npcBunniesAnimations::charactersModAnimCount[sourceSet][npcAnim];
			if(count > 0) return (count + division - 1) / division;
		}
		return 1;
	}

	int CurrentEditorFrameCount()
	{
		if(EditorNpcType() == npcBunniesCore::NpcAnim::FROG && ActualNpcAnimForEditorState(EditorNpcType(), editorState) == 3)
			return LogicalFrameCountForState(EditorNpcType(), editorState);
		int specialCount = SpecialEditorPreviewFrameCount(EditorNpcType(), editorState);
		if(specialCount > 0) return specialCount;
		return LogicalFrameCountForState(EditorNpcType(), editorState);
	}

	int EditorFrameCountForState(int state)
	{
		int oldState = editorState;
		editorState = state;
		int count = CurrentEditorFrameCount();
		editorState = oldState;
		return count;
	}

	void MoveEditorFrame(int delta)
	{
		if(delta == 0) return;
		if(editorField >= 0) CommitFieldEdit();
		ClampEditor();

		if(delta > 0)
		{
			int count = CurrentEditorFrameCount();
			if(editorFrame + 1 < count) editorFrame++;
			else
			{
				editorState++;
				if(editorState >= int(stateNames.length)) editorState = 0;
				editorFrame = 0;
			}
		}
		else
		{
			if(editorFrame > 0) editorFrame--;
			else
			{
				editorState--;
				if(editorState < 0) editorState = int(stateNames.length) - 1;
				int count = CurrentEditorFrameCount();
				editorFrame = count > 0 ? count - 1 : 0;
			}
		}

		ClampEditor();
		LoadEditorAdjustment();
	}

	bool SpecialEditorPreviewAnim(int npcType, int state, int &out animSet, int &out anim, int &out yOffset, float &out sizeX, SPRITE::Mode &out spriteMode)
	{
		int actualAnim = ActualNpcAnimForEditorState(npcType, state);
		yOffset = 0;
		sizeX = 1;
		spriteMode = SPRITE::NORMAL;

		if(npcType == npcBunniesCore::NpcAnim::DEVAN || npcType == ANIM::DEVILDEVAN)
		{
			array<int> devanIndexes = {8, 18, 18, 2, 2, 11, 11};
			if(actualAnim < 0) actualAnim = 0;
			if(actualAnim >= int(devanIndexes.length)) actualAnim = 0;
			animSet = ANIM::DEVILDEVAN;
			anim = actualAnim == 5 ? 14 : devanIndexes[actualAnim];
			yOffset = -16;
			return true;
		}

		if(npcType == npcBunniesCore::NpcAnim::EVA || npcType == ANIM::EVA)
		{
			if(actualAnim == 3 || actualAnim == 4)
			{
				animSet = ANIM::FLAG;
				anim = 6;
				sizeX = 1;
			}
			else
			{
				animSet = ANIM::EVA;
				anim = 0;
				sizeX = -1;
			}
			yOffset = -4;
			return true;
		}

		if(npcType == npcBunniesCore::NpcAnim::FROG)
		{
			if(actualAnim == 5) actualAnim = 3;
			array<int> frogIndexes = {2, 12, 12, 9, 0, 9, 0};
			if(actualAnim < 0) actualAnim = 0;
			if(actualAnim >= int(frogIndexes.length)) actualAnim = 0;
			animSet = ANIM::FROG;
			anim = frogIndexes[actualAnim];
			spriteMode = SPRITE::PALSHIFT;
			return true;
		}

		if(npcType == npcBunniesCore::NpcAnim::BIRD)
		{
			animSet = ANIM::BIRD;
			anim = 9;
			spriteMode = SPRITE::PALSHIFT;
			return true;
		}

		return false;
	}

	int SpecialEditorPreviewFrameCount(int npcType, int state)
	{
		int animSet = 0, anim = 0, yOffset = 0;
		float sizeX = 1;
		SPRITE::Mode spriteMode = SPRITE::NORMAL;
		if(!SpecialEditorPreviewAnim(npcType, state, animSet, anim, yOffset, sizeX, spriteMode)) return -1;
		if(jjAnimSets[animSet].firstAnim == 0) jjAnimSets[animSet].load();
		if(jjAnimSets[animSet].firstAnim == 0) return 1;
		int count = jjAnimations[jjAnimSets[animSet].firstAnim + anim].frameCount;
		return count > 0 ? count : 1;
	}

	void ClampEditor()
	{
		if(editorCategory < 0) editorCategory = CATEGORY_COUNT - 1;
		if(editorCategory >= CATEGORY_COUNT) editorCategory = 0;
		if(editorTypeIndex < 0) editorTypeIndex = int(npcBunniesCore::npcList.length) - 1;
		if(editorTypeIndex >= int(npcBunniesCore::npcList.length)) editorTypeIndex = 0;
		if(editorState < 0) editorState = int(stateNames.length) - 1;
		if(editorState >= int(stateNames.length)) editorState = 0;
		int count = CurrentEditorFrameCount();
		if(editorFrame < 0) editorFrame = count - 1;
		if(editorFrame >= count) editorFrame = 0;
	}

	string FormatScale(float value)
	{
		bool negative = value < 0;
		float absolute = negative ? -value : value;
		int tenths = int(absolute * 10 + 0.5);
		return (negative ? "-" : "") + (tenths / 10) + "." + (tenths % 10);
	}

	bool IsEditorFloatField(int action)
	{
		return action == NPC_HAT_ACTION_SCALE_X || action == NPC_HAT_ACTION_SCALE_Y;
	}

	bool IsEditorNumericField(int action)
	{
		return action == NPC_HAT_ACTION_FRAME || action == NPC_HAT_ACTION_POS_X || action == NPC_HAT_ACTION_POS_Y
			|| action == NPC_HAT_ACTION_ROTATION || action == NPC_HAT_ACTION_SCALE_X || action == NPC_HAT_ACTION_SCALE_Y;
	}

	string EditorFieldValue(int action)
	{
		if(action == NPC_HAT_ACTION_FRAME) return "" + editorFrame;
		if(action == NPC_HAT_ACTION_POS_X) return "" + editorX;
		if(action == NPC_HAT_ACTION_POS_Y) return "" + editorY;
		if(action == NPC_HAT_ACTION_ROTATION) return "" + editorRotation;
		if(action == NPC_HAT_ACTION_SCALE_X) return FormatScale(editorScaleX);
		if(action == NPC_HAT_ACTION_SCALE_Y) return FormatScale(editorScaleY);
		return "";
	}

	void SetEditorFieldValue(int action, const string &in value)
	{
		if(value == "" || value == "-" || value == "." || value == "-.") return;
		if(action == NPC_HAT_ACTION_FRAME)
		{
			editorFrame = parseInt(value);
			ClampEditor();
			LoadEditorAdjustment();
			return;
		}
		if(action == NPC_HAT_ACTION_POS_X) editorX = parseInt(value);
		else if(action == NPC_HAT_ACTION_POS_Y) editorY = parseInt(value);
		else if(action == NPC_HAT_ACTION_ROTATION) editorRotation = parseInt(value);
		else if(action == NPC_HAT_ACTION_SCALE_X) editorScaleX = parseFloat(value);
		else if(action == NPC_HAT_ACTION_SCALE_Y) editorScaleY = parseFloat(value);
		else return;
		SaveEditorAdjustment();
	}

	void BeginFieldEdit(int action)
	{
		if(!IsEditorNumericField(action)) return;
		editorField = action;
		editorFieldValue = EditorFieldValue(action);
		editorFieldFresh = true;
	}

	void CommitFieldEdit()
	{
		if(editorField < 0) return;
		SetEditorFieldValue(editorField, editorFieldValue);
		editorField = -1;
		editorFieldValue = "";
		editorFieldFresh = false;
	}

	void AppendEditChar(const string &in value)
	{
		if(editorFieldFresh)
		{
			editorFieldValue = "";
			editorFieldFresh = false;
		}
		if(value == "-")
		{
			if(int(editorFieldValue.length) > 0 && editorFieldValue.substr(0, 1) == "-") editorFieldValue = editorFieldValue.substr(1);
			else editorFieldValue = "-" + editorFieldValue;
			return;
		}
		if(value == "." && (!IsEditorFloatField(editorField) || editorFieldValue.findFirst(".") >= 0)) return;
		editorFieldValue += value;
	}

	bool ProcessEditorTextInput()
	{
		if(editorField < 0) return false;
		int key = -1;
		for(int i = 0x30; i <= 0x39 && key < 0; i++) if(jjKey[i]) key = i;
		for(int i = 0x60; i <= 0x69 && key < 0; i++) if(jjKey[i]) key = i;
		if(key < 0 && (jjKey[0xBD] || jjKey[0x6D])) key = 0xBD;
		if(key < 0 && (jjKey[0xBE] || jjKey[0x6E])) key = 0xBE;
		if(key < 0 && jjKey[0x08]) key = 0x08;
		if(key < 0 && jjKey[0x0D]) key = 0x0D;
		if(key < 0 && jjKey[0x1B]) key = 0x1B;
		if(key < 0) { editorLastKey = -1; return true; }
		if(key == editorLastKey) return true;
		editorLastKey = key;
		if(key >= 0x30 && key <= 0x39) AppendEditChar("" + (key - 0x30));
		else if(key >= 0x60 && key <= 0x69) AppendEditChar("" + (key - 0x60));
		else if(key == 0xBD) AppendEditChar("-");
		else if(key == 0xBE) AppendEditChar(".");
		else if(key == 0x08)
		{
			if(editorFieldFresh) { editorFieldValue = ""; editorFieldFresh = false; }
			else if(int(editorFieldValue.length) > 0) editorFieldValue = editorFieldValue.substr(0, int(editorFieldValue.length) - 1);
		}
		else if(key == 0x0D) CommitFieldEdit();
		else if(key == 0x1B) editorField = -1;
		return true;
	}

	string StepperText(int action, const string &in text)
	{
		if(editorField != action) return text;
		int colon = int(text.findFirst(":"));
		if(colon >= 0) return text.substr(0, colon + 2) + editorFieldValue;
		return editorFieldValue;
	}

	int EditorSmallTextWidth(const string &in text)
	{
		// Keep this deliberately conservative. The earlier variable-width guess
		// underestimated JJ2's small font and pushed the > button into the text.
		// Eight pixels per character overestimates narrow glyphs a little, but it
		// keeps every current value readable while still placing > much closer than
		// the old fixed x + 70 + w position.
		return int(text.length) * 8;
	}

	void DrawStepper(jjCANVAS@ canvas, int x, int y, int w, int action, int delta, const string &in text)
	{
		string shown = StepperText(action, text);
		int shownWidth = EditorSmallTextWidth(shown);
		int rightButtonX = x + 22 + shownWidth + 32; // +10px safety gap after measured text
		if(rightButtonX < x + 42) rightButtonX = x + 42;
		int fieldW = rightButtonX - (x + 20) - 2;
		if(fieldW < 16) fieldW = 16;

		DbDrawButton(canvas, @editorWindow, x, y, 16, 16, action, 0, -delta, "<");
		if(IsEditorNumericField(action))
		{
			canvas.drawRectangle(x + 20, y - 7, fieldW, 16, editorField == action ? 80 : 0, SPRITE::TRANSLUCENT);
			DbAddControl(@editorWindow, x + 20, y, fieldW, 16, NPC_HAT_ACTION_EDIT_FIELD, 0, action, shown);
		}
		else DbAddControl(@editorWindow, x + 20, y, fieldW, 16, action, 0, delta, shown);
		canvas.drawString(x + 22, y, shown, STRING::SMALL, editorField == action ? STRING::PALSHIFT : STRING::NORMAL, editorField == action ? 80 : 0);
		DbDrawButton(canvas, @editorWindow, rightButtonX, y, 16, 16, action, 0, delta, ">");
	}

	void ApplyEditorAction(int action, int delta)
	{
		if(editorField >= 0 && action != NPC_HAT_ACTION_EDIT_FIELD) CommitFieldEdit();
		if(action == NPC_HAT_ACTION_EDIT_FIELD) { BeginFieldEdit(delta); return; }
		if(action == NPC_HAT_ACTION_CATEGORY) editorCategory += delta;
		else if(action == NPC_HAT_ACTION_TYPE) editorTypeIndex += delta;
		else if(action == NPC_HAT_ACTION_STATE) editorState += delta;
		else if(action == NPC_HAT_ACTION_FRAME) editorFrame += delta;
		else if(action == NPC_HAT_ACTION_INCREMENT) editorIncrement = editorIncrement == 1 ? 10 : 1;
		else if(action == NPC_HAT_ACTION_POS_X) { editorX += delta * editorIncrement; SaveEditorAdjustment(); return; }
		else if(action == NPC_HAT_ACTION_POS_Y) { editorY += delta * editorIncrement; SaveEditorAdjustment(); return; }
		else if(action == NPC_HAT_ACTION_ROTATION) { editorRotation += delta * editorIncrement; SaveEditorAdjustment(); return; }
		else if(action == NPC_HAT_ACTION_SCALE_X) { editorScaleX += delta * (editorIncrement == 1 ? 0.1 : 1); SaveEditorAdjustment(); return; }
		else if(action == NPC_HAT_ACTION_SCALE_Y) { editorScaleY += delta * (editorIncrement == 1 ? 0.1 : 1); SaveEditorAdjustment(); return; }
		else if(action == NPC_HAT_ACTION_MOUSE) { editorMouseMode = !editorMouseMode; return; }
		ClampEditor();
		LoadEditorAdjustment();
	}

	void HandleEditorFireKey(jjPLAYER@ play)
	{
		if(play.keyFire && !editorPressedFire)
		{
			if(editorField >= 0) CommitFieldEdit();
			CopyPreviousEditorFrameAdjustment();
			editorPressedFire = true;
		}
		else if(!play.keyFire) editorPressedFire = false;
	}

	void DrawNpcPreviewFrame(jjCANVAS@ canvas, int x, int y, int npcType, int state, int frame)
	{
		int previewFrameCount = EditorFrameCountForState(state);
		int previewFrame = previewFrameCount > 0 ? frame % previewFrameCount : 0;
		if(previewFrame < 0) previewFrame += previewFrameCount;

		int previewAnimSet = 0, previewAnim = 0, previewYOffset = 0;
		float previewSizeX = 1;
		SPRITE::Mode previewSpriteMode = SPRITE::NORMAL;
		if(SpecialEditorPreviewAnim(npcType, state, previewAnimSet, previewAnim, previewYOffset, previewSizeX, previewSpriteMode))
		{
			if(jjAnimSets[previewAnimSet].firstAnim == 0) jjAnimSets[previewAnimSet].load();
			if(jjAnimSets[previewAnimSet].firstAnim > 0)
				canvas.drawResizedSprite(x, y + previewYOffset, previewAnimSet, previewAnim, previewFrame, previewSizeX, 1, previewSpriteMode, 0);
		}
		else if(npcType >= 0 && npcType < int(npcBunniesAnimations::emptyCharSet.length))
		{
			int frameIndex = NpcFrameIndexForState(npcType, state, previewFrame);
			int animSet = ANIM::CUSTOM[npcBunniesAnimations::emptyCharSet[npcType]];
			if(jjAnimSets[animSet].firstAnim > 0)
				canvas.drawSpriteFromCurFrame(x, y, jjAnimations[jjAnimSets[animSet].firstAnim].firstFrame + frameIndex, 0, SPRITE::NORMAL, 0);
		}
	}

	void DrawEditorPreviewAccessory(jjCANVAS@ canvas, int x, int y, int npcType, int state, int frame, int accessoryFrame, bool useCurrentAdjustment)
	{
		int frameCount = FrameCount(editorCategory);
		if(frameCount <= 0) return;

		if(useCurrentAdjustment)
		{
			canvas.drawRotatedSprite(x + editorX, y + editorY, ANIM::CUSTOM[accessorySet], editorCategory, accessoryFrame, editorRotation, editorScaleX, editorScaleY);
			return;
		}

		int previewFrameCount = EditorFrameCountForState(state);
		int previewFrame = previewFrameCount > 0 ? frame % previewFrameCount : 0;
		if(previewFrame < 0) previewFrame += previewFrameCount;

		int charSlot = CharacterSlotForNpcType(npcType);
		int storageSlot = StorageSlotForCharacterSlot(charSlot);
		int physicalFrameIndex = NpcFrameIndexForState(npcType, state, previewFrame);
		int frameIndex = CoordinateFrameIndexForNpcState(npcType, state, previewFrame);
		if(frameIndex < 0) frameIndex = physicalFrameIndex;
		int storedFrameIndex = StoredAdjustmentFrameForNpcState(npcType, state, physicalFrameIndex);
		int fallbackSlot = DefaultCharacterSlotForCustom(charSlot);
		int fallbackStorageSlot = StorageSlotForCharacterSlot(fallbackSlot);
		int fallbackFrameIndex = frameIndex;
		int fallbackStoredFrameIndex = storedFrameIndex;
		if(fallbackSlot != charSlot)
		{
			fallbackFrameIndex = FrameIndexForRabbitAnim(fallbackSlot, stateRabbitAnims[state], previewFrame);
			if(fallbackFrameIndex < 0) fallbackFrameIndex = frameIndex;
			fallbackStoredFrameIndex = StoredAdjustmentFrameForCharacterSlotNpcState(fallbackSlot, state, fallbackFrameIndex);
		}

		int index = DrawAdjustmentIndex(editorCategory, storageSlot, frameIndex, storedFrameIndex, fallbackStorageSlot, fallbackFrameIndex, fallbackStoredFrameIndex);
		if(index < 0) return;
		canvas.drawRotatedSprite(
			x + adjustX[editorCategory][index],
			y + adjustY[editorCategory][index],
			ANIM::CUSTOM[accessorySet],
			editorCategory,
			accessoryFrame,
			rotation[editorCategory][index],
			scaleX[editorCategory][index],
			scaleY[editorCategory][index]
		);
	}

	void DrawNpcPreview(jjPLAYER@ play, jjCANVAS@ canvas)
	{
		// Draw the editor preview directly on the score canvas. Calling jjDraw*
		// from OnDrawScore queues world-layer sprites after the world has already
		// been drawn, which is what made the selected bunny/accessory frame blink.
		// This keeps the original transparent look: no preview rectangle/background.
		int x = int(play.xPos - play.cameraX + 96);
		int y = int(play.yPos - play.cameraY);

		int npcType = EditorNpcType();
		const int previewRowOffset = 32 * 3;

		// Upper row: accessory index 1, three tiles above the normal preview row.
		DrawNpcPreviewFrame(canvas, x, y - previewRowOffset, npcType, editorState, editorFrame);
		DrawEditorPreviewAccessory(canvas, x, y - previewRowOffset, npcType, editorState, editorFrame, 1, true);
		DrawNpcPreviewFrame(canvas, x + 64, y - previewRowOffset, npcType, editorState, jjGameTicks / 8);
		DrawEditorPreviewAccessory(canvas, x + 64, y - previewRowOffset, npcType, editorState, jjGameTicks / 8, 1, false);

		// Existing lower row: accessory index 0.
		DrawNpcPreviewFrame(canvas, x, y, npcType, editorState, editorFrame);
		DrawEditorPreviewAccessory(canvas, x, y, npcType, editorState, editorFrame, 0, true);
		DrawNpcPreviewFrame(canvas, x + 64, y, npcType, editorState, jjGameTicks / 8);
		DrawEditorPreviewAccessory(canvas, x + 64, y, npcType, editorState, jjGameTicks / 8, 0, false);
	}

	void HandleEditorMouse(jjPLAYER@ play)
	{
		if(!editorMouseMode) { editorMouseWasDown = false; return; }
		bool down = jjKey[0x01];
		bool over = DbPointIn(jjMouseX, jjMouseY + 8, editorWindow.x, editorWindow.y, editorWindow.w, editorWindow.h);
		if(down && !editorMouseWasDown && !over)
		{
			float previewX = play.xPos + 96;
			float previewY = play.yPos;
			editorX = int(play.cameraX + jjMouseX - previewX);
			editorY = int(play.cameraY + jjMouseY - previewY);
			SaveEditorAdjustment();
		}
		editorMouseWasDown = down;
	}

	void OnMain()
	{
		if(!npcBunniesCore::ENABLE_NPC_ACCESSORIES) return;
		if(!editorOpen) return;
		if(ProcessEditorTextInput()) return;
		if(!editorPressedKey)
		{
			if(jjKey[0x26]) { MoveEditorFrame(1); }
			else if(jjKey[0x28]) { MoveEditorFrame(-1); }
			else if(jjKey[0x57]) { editorY -= editorIncrement; SaveEditorAdjustment(); }
			else if(jjKey[0x53]) { editorY += editorIncrement; SaveEditorAdjustment(); }
			else if(jjKey[0x41]) { editorX -= editorIncrement; SaveEditorAdjustment(); }
			else if(jjKey[0x44]) { editorX += editorIncrement; SaveEditorAdjustment(); }
			else if(jjKey[0x51]) { editorRotation += editorIncrement; SaveEditorAdjustment(); }
			else if(jjKey[0x45]) { editorRotation -= editorIncrement; SaveEditorAdjustment(); }
			editorPressedKey = true;
		}
		if(!jjKey[0x26] && !jjKey[0x28] && !jjKey[0x57] && !jjKey[0x53] && !jjKey[0x41] && !jjKey[0x44] && !jjKey[0x51] && !jjKey[0x45]) editorPressedKey = false;
	}

	void OnDrawScore(jjPLAYER@ play, jjCANVAS@ canvas)
	{
		if(!npcBunniesCore::ENABLE_NPC_ACCESSORIES) return;
		if(!editorOpen) return;
		EnsureLoaded();
		ClampEditor();
		DbBegin(@editorWindow, "npcHatEditor", "NPC Hat Editor", jjResolutionWidth - 400, 72, 390, 230);
		editorWindow.draggable = true;
		editorWindow.closeButton = true;
		editorWindow.titleBar = true;
		editorWindow.backgroundColor = 32;
		editorWindow.focusColor = 15;
		DbDrawFrame(canvas, @editorWindow);
		int x = editorWindow.x + 10;
		int y = editorWindow.y + 32;
		DrawStepper(canvas, x, y, 140, NPC_HAT_ACTION_CATEGORY, 1, "Category: " + categoryNames[editorCategory]); y += 22;
		DrawStepper(canvas, x, y, 120, NPC_HAT_ACTION_TYPE, 1, "NPC: " + EditorNpcTypeName()); y += 22;
		DrawStepper(canvas, x, y, 120, NPC_HAT_ACTION_STATE, 1, "State: " + stateNames[editorState]); y += 22;
		DrawStepper(canvas, x, y, 82, NPC_HAT_ACTION_FRAME, 1, "Frame: " + editorFrame);
		DrawStepper(canvas, x + 160, y, 90, NPC_HAT_ACTION_INCREMENT, 1, "Inc: " + editorIncrement); y += 24;
		DrawStepper(canvas, x, y, 70, NPC_HAT_ACTION_POS_X, 1, "X: " + editorX);
		DrawStepper(canvas, x + 140, y, 70, NPC_HAT_ACTION_POS_Y, 1, "Y: " + editorY); y += 22;
		DrawStepper(canvas, x, y, 80, NPC_HAT_ACTION_ROTATION, 1, "Rot: " + editorRotation); y += 22;
		DrawStepper(canvas, x, y, 82, NPC_HAT_ACTION_SCALE_X, 1, "SX: " + FormatScale(editorScaleX));
		DrawStepper(canvas, x + 150, y, 82, NPC_HAT_ACTION_SCALE_Y, 1, "SY: " + FormatScale(editorScaleY)); y += 24;
		DbDrawButton(canvas, @editorWindow, x, y, 110, 16, NPC_HAT_ACTION_MOUSE, 0, 0, "Mouse: " + (editorMouseMode ? "ON" : "OFF"), true, editorMouseMode);
		DbResetResult(@editorResult);
		DbProcessMouse(@editorWindow, @editorResult);
		if(editorResult.closeRequested) { editorOpen = false; editorPressedFire = false; }
		else if(editorResult.activated) ApplyEditorAction(editorResult.action, editorResult.b);
		HandleEditorMouse(play);
		HandleEditorFireKey(play);
		DrawNpcPreview(play, canvas);
	}

	bool OnLocalChat(string &in text)
	{
		if(!jjRegexMatch(text, "^!npcHatEdit(?:\\s+(?:ON|OFF))?$", true) && !jjRegexMatch(text, "^!npcHatEditor(?:\\s+(?:ON|OFF))?$", true)) return false;
		if(!npcBunniesCore::ENABLE_NPC_ACCESSORIES) return true;
		if(!npcBunniesCore::LocalPlayerHasNpcBunniesAdminAccess()) return true;
		int space = int(text.findFirst(" "));
		if(space >= 0)
		{
			int textLength = int(text.length);
			string arg = text.substr(space + 1, textLength - space - 1);
			if(jjRegexMatch(arg, "^OFF$", true)) { editorOpen = false; editorPressedFire = false; }
			else if(jjRegexMatch(arg, "^ON$", true)) editorOpen = true;
			else editorOpen = !editorOpen;
		}
		else editorOpen = !editorOpen;
		ClampEditor();
		LoadEditorAdjustment();
		return true;
	}
}

}