Downloads containing charsNewCore.asc

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

File preview

#pragma require "CharactersMinAnim.asc"
#include "CharactersMinAnim.asc"

namespace RABBIT //save some extra typing
{
	array<Anim> Anims = { //This should correspond exactly to the order of animations in your .j2a. I'm assuming you're using the same order that TSF does.
		AIRBOARD, AIRBOARDTURN, BUTTSTOMPLAND, CORPSE, DIE, DIVE, DIVEFIREQUIT, DIVEFIRERIGHT, DIVEUP, EARBRACHIATE, ENDOFLEVEL, FALL, FALLBUTTSTOMP, FALLLAND, FIRE, FIREUP, FIREUPQUIT, FROG, HANGFIREQUIT, HANGFIREREST, HANGFIREUP, HANGIDLE1, HANGIDLE2, HANGINGFIREQUIT, HANGINGFIRERIGHT, HELICOPTER, HELICOPTERFIREQUIT, HELICOPTERFIRERIGHT, HPOLE, HURT, IDLE1, IDLE2, IDLE3, IDLE4, IDLE5, JUMPFIREQUIT, JUMPFIRERIGHT, JUMPING1, JUMPING2, JUMPING3, LEDGEWIGGLE, LIFT, LIFTJUMP, LIFTLAND, LOOKUP, LOOPY, PUSH, QUIT, REV1, REV2, REV3, RIGHTFALL, RIGHTJUMP, ROLLING, RUN1, RUN2, RUN3, SKID1, SKID2, SKID3, SPRING, STAND, STATIONARYJUMP, STATIONARYJUMPEND, STATIONARYJUMPSTART, STONED, SWIMDOWN, SWIMRIGHT, SWIMTURN1, SWIMTURN2, SWIMUP, SWINGINGVINE, TELEPORT, TELEPORTFALL, TELEPORTFALLING, TELEPORTFALLTELEPORT, TELEPORTSTAND, VPOLE
	};
}

namespace CharsNew
{
	const string Filename = "CharsEnable.asdat";
	const string YoshiPreferenceFilename = "CharsYoshi.asdat";
	const string HidePreferenceFilename = "CharsNewHide.asdat";
	int IS_REMOTE_ADMIN = -1; //-1 = didn't check, 0 = checked but doesn't have privileges, 1 = checked and has privileges
	bool DRAW_LIVES_FOR_ALL_CHARACTERS = true;
	bool SPLITSCREEN_SOUND_SYSTEM = false; //for splitscreen players. If disabled only the latest morph will have the character sounds changed.
	//i.e. You have 2 players and you morph one to Tails, then one to Yoshi. You will hear Yoshi sounds for Tails as well.
	bool SILENT_SOUND_SYSTEM = true;
	bool CHANGE_SPAZ_BOOT_CHARACTER = false;
	bool LOAD_BLUE_YOSHI = false;
	bool CHARS_NEW_HIDING = true;
	bool HANG_LEDGES_ENABLED = false;
	bool HANG_LEDGES_LOCAL_ENABLED = true;
	// Editor-only value. Keep it outside the real Jazz/Spaz animation IDs:
	// Spaz bird animations occupy 78 and 79.
	const int HANG_LEDGE_EDITOR_ANIM = 89;
	const int HANG_LEDGE_SOURCE_ANIM = 20;
	const string NamingSchemePreferenceFilename = "CharsNames.asdat";
	const string TracePreferenceFilename = "CharsTrace.asdat";

	// Extra visual Y offsets for the resized ledge-hang animation.
	// Negative is higher on screen. Adjust these values directly if needed.
	const float HANG_LEDGE_TINY_Y_OFFSET = -16;
	const float HANG_LEDGE_GIANT_Y_OFFSET = 0;
	bool OLD_NAMING_SCHEME = false;
	bool FROG_FORCE_ENABLED = true;
	bool MONSTER_MORPHS_ENABLED = true;
	bool OBJECT_MORPHS_ENABLED = true;
	bool TRACE_ADMIN_ENABLED = true;
	bool TRACE_FEATURE_ENABLED = true;
	bool TRACE_ENABLED = false;
	bool TRACE_ONMAIN_FALLBACK_ENABLED = true;
	int TRACE_EXPIRE_TICKS = 15; //70 ticks = 1 second. Default trace lifetime is 2 seconds.
	array<string> TRACE_COLOR_NAMES = {"default", "red", "yellow", "green", "blue", "lime", "purple", "pink", "white", "black"};
	array<int> TRACE_COLOR_VALUES = {39, 24, 40, 16, 32, 80, 88, 56, 15, 1};
	array<int> TRACE_YELLOW_RAMP = {40, 41, 42, 59, 60, 61, 62, 63};
	array<int> TRACE_RAINBOW_COLORS = {24, 48, 40, 16, 32, 88, 95}; // red, orange, yellow, green, blue, indigo, violet
	int TRACE_COLOR_INDEX = 0;
	int TRACE_COLOR_VALUE = 39;
	const int TRACE_STYLE_NORMAL = 0;
	const int TRACE_STYLE_INVERT = 1;
	const int TRACE_STYLE_CONST = 2;
	int TRACE_STYLE = TRACE_STYLE_NORMAL;
	const int TRACE_MODE_COLOR = 0;
	const int TRACE_MODE_RAINBOW = 1;
	const int TRACE_MODE_RANDOM = 2;
	int TRACE_MODE = TRACE_MODE_COLOR;


	//index for 1.23 animations
	array<int> animIndex = {1, 2, 3, 4, 5, 6, 7, 8, 9, 11, 12, 13, 14, 15, 16, 17, 18, 19, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 55, 56, 57, 58, 59, 60, 61, 63, 67, 68, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92};
	array<int> frogLoriColdspotX = {-14, -15, -11, -8,-10,-13, -8, -7, -6,-10,-12,-20,-16,-12,-17,-17,-20,-20,-16,-17,-20,-20,-20};
	array<int> frogLoriColdspotY = { -2,   4,  -5,-10,-15,-10,-18,-24,-30,-31,-32,-34,-38,-41,-40,-35,-29,-26, -8,-12,-29,-29,-29};
	array<int> animIndexLookup = {
		0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 0, 9, 10, 11, 12, 13,
		14, 15, 16, 17, 0, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28,
		29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44,
		0, 0, 0, 0, 0, 0, 0, 45, 46, 47, 48, 49, 50, 51, 0, 52,
		0, 0, 0, 53, 54, 0, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64,
		65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 0, 0, 0,
		0, 0, 0, 0, 0, 0, 0, 0, 78, 79, 0, 0, 0, 0, 0, 0,
		0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
	};
	int revertIndex(int index)
	{
		if(index >= 0 && index < int(animIndexLookup.length))
			return animIndexLookup[index];
		return 0;
	}

	bool loadLoriShirt = true;
	enum CharAnim 
	{
		LORI2 = 0,
		JAZZ = 1,
		SPAZ = 2,
		LORI = 3,
		FROG = 4,
		BIRD = 5,
		BIRD2 = 6,
		LORI1 = 7,
		MARIO = 8,
		SONIC = 9,
		TAILS = 10,
		JJ1 = 11,
		DEVAN = 12,
		YOSHI = 13,
		WITCH = 14,
		HEAD_JAZZ = 15,
		HEAD_SPAZ = 16,
		HEAD_LORI = 17,
		HEAD_FROG = 18,
		HEAD_BIRD = 19,
		HEAD_BIRD2 = 20,
		HEAD_MARIO = 21,
		HEAD_SONIC = 22,
		HEAD_TAILS = 23,
		HEAD_EGGMAN = 24,
		HEAD_DEVAN = 25,
		HEAD_YOSHI = 26,
		EVA = 27
	}

	array<float> characterNameYOffset = {
		0, 0, 0, 4, 0, 0, 0, 4,
		0, 0, 8, 0, 16, 16, 0, 0,
		0, 0, 0, 0, 0, 0, 0, 0,
		0, 0, 0, 0
	};

	bool isPlayerHead(int char)
	{
		return CharAnim::HEAD_JAZZ <= char && char <= CharAnim::HEAD_BIRD2;
	}
	bool isCustomHead(int char)
	{
		return CharAnim::HEAD_MARIO <= char && char <= CharAnim::HEAD_YOSHI;
	}

	bool isFlyingHead(int char)
	{
		return char == CharAnim::HEAD_BIRD2 
			|| char == CharAnim::HEAD_FROG 
			|| char == CharAnim::HEAD_EGGMAN;
	}

	class CharacterVariables
	{
		int Anim;
		int Head;
		int Set = -1;
		int CharactersMinSourceSet = -1;
		string Name;
		string File;
		bool EnabledMorph = true;
		int CustomMoves;
		CHAR::Char DefaultCharacter;
		int HeadOffsetY; //HeadOffsetX is different depending on direction, so it is in getHeadOffsetX() instead
		int LivesX;
		int LivesY;
		int LivesAnim;
		int HeadAnimSet;
		
		array<int> adjustX(0,0);
		array<int> adjustY(0,0); 
		array<int> animRel(0,0); 
		array<float> sizeX(0,1); 
		array<float> sizeY(0,1); 
		
		void add(int Anim, int Head, int Set, string Name, bool HasFile, int CustomMoves, CHAR::Char DefaultCharacter, int HeadOffsetY, int LivesX, int LivesY, int LivesAnim)
		{
			this.Anim = Anim;
			this.Head = Head;
			this.Set = Set;
			this.Name = Name;
			this.CustomMoves = CustomMoves;
			this.DefaultCharacter = DefaultCharacter;
			this.HeadOffsetY = HeadOffsetY;
			this.LivesX = LivesX;
			this.LivesY = LivesY;
			this.LivesAnim = LivesAnim;
			
			this.File = HasFile ? (Name + ".asdat") : "";
			if(HasFile)
			{
				this.animRel.resize(90);
				this.adjustX.resize(90);
				this.adjustY.resize(90);
				this.sizeX.resize(90);
				this.sizeY.resize(90);
				for(int i=0; i<90; i++)
				{
					this.sizeX[i] = 1;
					this.sizeY[i] = 1;
				}
			}
			if(Anim == CharAnim::YOSHI)
			{
				this.animRel.resize(90);
				animRel = {0, 1, 2, 3, 4, 5, 80, 80, 8, 9, 10, 11, 2, 13, 81, 15, 81, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0};
			}
			else if(Anim == CharAnim::JJ1)
			{
				this.animRel.resize(90);
				animRel = {4, 4, 5, 8, 8, 13, 13, 13, 13, 1, 2, 6, 6, 6, 14, 14, 14, 14, 14, 14, 14, 17, 17, 17, 14, 12, 12, 12, 12, 10, 0, 0, 0, 0, 0, 1, 14, 5, 5, 5, 11, 11, 11, 11, 3, 1, 9, 17, 1, 2, 2, 5, 5, 12, 1, 2, 2, 15, 15, 15, 6, 17, 3, 3, 3, 16, 7, 7, 7, 7, 7, 7, 0, 0, 5, 0, 0, 12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0};
			}
			else if(Anim == CharAnim::TAILS)
			{
				this.animRel.resize(90);
				animRel = {0, 0, 1, 2, 3, 4, 4, 4, 4, 5, 6, 7, 8, 9, 10, 10, 10, 11, 12, 12, 12, 12, 12, 12, 12, 0, 0, 0, 13, 14, 9, 15, 16, 36, 18, 10, 10, 19, 19, 20, 21, 21, 21, 21, 16, 22, 23, 10, 24, 24, 24, 25, 25, 25, 6, 6, 24, 26, 27, 27, 20, 9, 28, 28, 28, 29, 30, 30, 30, 30, 30, 31, 32, 33, 3, 33, 33, 34, 17, 17, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0};
			}
			
			chars.insertLast(this);
		}
	}

	int EmptySet = 0;
	int GetSet()
	{
		//find an unused slot in ANIM::CUSTOM
		EmptySet++;
		while (jjAnimSets[ANIM::CUSTOM[EmptySet]].firstAnim != 0) ++EmptySet;
		return EmptySet;
	}

	bool SPAZ_LOAD_ONLY_NEEDED_ANIMS = true;
	void loadSpazBackupAnimations()
	{
		spazBackupSet = GetSet();
		jjANIMSET@ animToLoad = jjAnimSets[ANIM::CUSTOM[spazBackupSet]];
		if(!SPAZ_LOAD_ONLY_NEEDED_ANIMS)
		{
			animToLoad.load(0, "charactersMin.j2a");
		}
		else
		{
			int indAnim = jjIsTSF ? 4 : 5;
			array<uint> c = 
			{
				jjAnimations[jjAnimSets[ANIM::JAZZ] + indAnim].frameCount, 
				jjAnimations[jjAnimSets[ANIM::SPAZ] + indAnim].frameCount,
				jjAnimations[jjAnimSets[ANIM::LORI] + indAnim].frameCount
			};
			animToLoad.allocate(c);
			for(uint j=0; j<jjAnimations[jjAnimSets[ANIM::JAZZ] + indAnim].frameCount; j++) 
				jjAnimFrames[jjAnimations[animToLoad + 0] + j] = jjAnimFrames[jjAnimations[jjAnimSets[ANIM::JAZZ] + indAnim] + j];
			for(uint j=0; j<jjAnimations[jjAnimSets[ANIM::SPAZ] + indAnim].frameCount; j++) 
				jjAnimFrames[jjAnimations[animToLoad + 1] + j] = jjAnimFrames[jjAnimations[jjAnimSets[ANIM::SPAZ] + indAnim] + j];
			for(uint j=0; j<jjAnimations[jjAnimSets[ANIM::LORI] + indAnim].frameCount; j++) 
				jjAnimFrames[jjAnimations[animToLoad + 2] + j] = jjAnimFrames[jjAnimations[jjAnimSets[ANIM::LORI] + indAnim] + j];
		}
	}

	CHAR::Char defaultChar = CHAR::SPAZ;
	int defaultCharCount = 6;
	dictionary charDict = {{CHAR::JAZZ+'', 1}, {CHAR::SPAZ+'', 2}, {CHAR::LORI+'', 3}, {CHAR::FROG+'', 4}, {CHAR::BIRD+'', 5}, {CHAR::BIRD2+'', 6}};
	array<CharacterVariables> chars;

	int CharactersMinSourceForCharSlot(int charSlot)
	{
		switch(charSlot)
		{
			case CharAnim::LORI2:
				return 1;
			case CharAnim::LORI1:
				return 2;
			case CharAnim::MARIO:
				return 3;
			case CharAnim::SONIC:
				return 4;
			case CharAnim::TAILS:
				return 5;
			case CharAnim::JJ1:
				return 6;
			case CharAnim::DEVAN:
				return 7;
			case CharAnim::YOSHI:
				return LOAD_BLUE_YOSHI ? 9 : 8;
		}
		return -1;
	}

	void initializeCharacterVariables()
	{
		CharacterVariables newCharacterVariable;//Mario 20, Sonic 16, Eggman 8, Tails -16, Devan -24, Yoshi -24
		int LoriShirtSet = loadLoriShirt ? GetSet() : -1;
		newCharacterVariable.add(Anim: CharAnim::LORI2,			Head: -1,					Set: LoriShirtSet,	Name: "",			HasFile: false,		CustomMoves: 0,	DefaultCharacter: defaultChar, 							HeadOffsetY: 0, 	LivesX:	0,	LivesY: 0,	LivesAnim: -1);
		newCharacterVariable.add(Anim: CharAnim::JAZZ,			Head: -1,					Set: -1,			Name: "Jazz",		HasFile: false,		CustomMoves: 0,	DefaultCharacter: CHAR::JAZZ, 							HeadOffsetY: 0, 	LivesX:	0,	LivesY: 0,	LivesAnim: -1);
		newCharacterVariable.add(Anim: CharAnim::SPAZ,			Head: -1,					Set: -1,			Name: "Spaz",		HasFile: false,		CustomMoves: 0,	DefaultCharacter: CHAR::SPAZ, 							HeadOffsetY: 0, 	LivesX:	0,	LivesY: 0,	LivesAnim: -1);
		newCharacterVariable.add(Anim: CharAnim::LORI,			Head: -1,					Set: -1,			Name: "Lori",		HasFile: false,		CustomMoves: 0,	DefaultCharacter: jjIsTSF ? CHAR::LORI : CHAR::SPAZ, 	HeadOffsetY: 0, 	LivesX:	0,	LivesY: 0,	LivesAnim: -1);
		newCharacterVariable.add(Anim: CharAnim::FROG,			Head: -1,					Set: -1,			Name: "Frog",		HasFile: false,		CustomMoves: 1,	DefaultCharacter: CHAR::FROG,								HeadOffsetY: 0, 	LivesX:	0,	LivesY: 0,	LivesAnim: -1);
		newCharacterVariable.add(Anim: CharAnim::BIRD,			Head: -1,					Set: -1,			Name: "Bird",		HasFile: false,		CustomMoves: 1,	DefaultCharacter: CHAR::BIRD,								HeadOffsetY: 0, 	LivesX:	0,	LivesY: 0,	LivesAnim: -1);
		newCharacterVariable.add(Anim: CharAnim::BIRD2, 		Head: -1,					Set: -1,			Name: "Bird2",		HasFile: false,		CustomMoves: 0,	DefaultCharacter: CHAR::BIRD2, 							HeadOffsetY: 0, 	LivesX:	0,	LivesY: 0,	LivesAnim: -1);
		newCharacterVariable.add(Anim: CharAnim::LORI1,			Head: -1,					Set: GetSet(),		Name: "Lori",		HasFile: false,		CustomMoves: 0,	DefaultCharacter: defaultChar, 							HeadOffsetY: 0, 	LivesX:	0,	LivesY: 0,	LivesAnim: jjIsTSF ? -1 : 80);
		newCharacterVariable.add(Anim: CharAnim::MARIO,			Head: -1,					Set: GetSet(),		Name: "Mario",		HasFile: false,		CustomMoves: 0,	DefaultCharacter: defaultChar, 							HeadOffsetY: 0, 	LivesX:	2,	LivesY: 0,	LivesAnim: 82);
		newCharacterVariable.add(Anim: CharAnim::SONIC,			Head: -1,					Set: GetSet(),		Name: "Sonic",		HasFile: false,		CustomMoves: 0,	DefaultCharacter: defaultChar, 							HeadOffsetY: 0, 	LivesX:	2,	LivesY: 0,	LivesAnim: 82);
		newCharacterVariable.add(Anim: CharAnim::TAILS,			Head: -1,					Set: GetSet(),		Name: "Tails",		HasFile: false,		CustomMoves: 1,	DefaultCharacter: defaultChar, 							HeadOffsetY: 0, 	LivesX:	2,	LivesY: 25,LivesAnim: 35);
		newCharacterVariable.add(Anim: CharAnim::JJ1,			Head: -1,					Set: GetSet(),		Name: "JJ1",		HasFile: false,		CustomMoves: 0,	DefaultCharacter: CHAR::JAZZ, 							HeadOffsetY: 0, 	LivesX:	0,	LivesY: 0,	LivesAnim: -1);
		newCharacterVariable.add(Anim: CharAnim::DEVAN,			Head: -1,					Set: GetSet(),		Name: "Devan",		HasFile: false,		CustomMoves: 1,	DefaultCharacter: defaultChar, 							HeadOffsetY: 0, 	LivesX:	0,	LivesY: 40,LivesAnim:  1);
		newCharacterVariable.add(Anim: CharAnim::YOSHI,			Head: -1,					Set: GetSet(),		Name: "Yoshi",		HasFile: false,		CustomMoves: 0,	DefaultCharacter: defaultChar, 							HeadOffsetY: 0, 	LivesX:	7,	LivesY: 25,LivesAnim: 82);
		newCharacterVariable.add(Anim: CharAnim::WITCH,			Head: -1,					Set: -1,			Name: "Witch",		HasFile: false,		CustomMoves: 1,	DefaultCharacter: defaultChar, 							HeadOffsetY: 0, 	LivesX:	10,	LivesY: 20,LivesAnim:  1);
		newCharacterVariable.add(Anim: CharAnim::HEAD_JAZZ,		Head: -1,					Set: -1,			Name: "JazzHead",	HasFile: false,		CustomMoves: 0,	DefaultCharacter: defaultChar, 							HeadOffsetY: 16, 	LivesX:	0,	LivesY: 0,	LivesAnim:  3);
		newCharacterVariable.add(Anim: CharAnim::HEAD_SPAZ,		Head: -1,					Set: -1,			Name: "SpazHead",	HasFile: false,		CustomMoves: 0,	DefaultCharacter: defaultChar, 							HeadOffsetY: 16, 	LivesX:	0,	LivesY: 0,	LivesAnim: jjIsTSF ? 5 : 4);
		newCharacterVariable.add(Anim: CharAnim::HEAD_LORI,		Head: -1,					Set: -1,			Name: "LoriHead",	HasFile: false,		CustomMoves: 0,	DefaultCharacter: defaultChar, 							HeadOffsetY: 16, 	LivesX:	0,	LivesY: 0,	LivesAnim: jjIsTSF ? 4 : 80);
		newCharacterVariable.add(Anim: CharAnim::HEAD_FROG,		Head: -1,					Set: -1,			Name: "FrogHead",	HasFile: false,		CustomMoves: 1,	DefaultCharacter: CHAR::BIRD2, 							HeadOffsetY: 16, 	LivesX:	0,	LivesY: 0,	LivesAnim:  2);
		newCharacterVariable.add(Anim: CharAnim::HEAD_BIRD,		Head: -1,					Set: -1,			Name: "BirdHead",	HasFile: false,		CustomMoves: 0,	DefaultCharacter: defaultChar, 							HeadOffsetY: 16, 	LivesX:	0,	LivesY: 0,	LivesAnim:  0);
		newCharacterVariable.add(Anim: CharAnim::HEAD_BIRD2,	Head: -1,					Set: -1,			Name: "Bird2Head",	HasFile: false,		CustomMoves: 1,	DefaultCharacter: CHAR::BIRD2, 							HeadOffsetY: 16, 	LivesX:	0,	LivesY: 0,	LivesAnim:  1);
		newCharacterVariable.add(Anim: CharAnim::HEAD_MARIO,	Head: CharAnim::MARIO,		Set: -1,			Name: "MarioHead",	HasFile: false,		CustomMoves: 0,	DefaultCharacter: defaultChar, 							HeadOffsetY: 20, 	LivesX:	0,	LivesY: 0,	LivesAnim: 82);
		newCharacterVariable.add(Anim: CharAnim::HEAD_SONIC,	Head: CharAnim::SONIC,		Set: -1,			Name: "SonicHead",	HasFile: false,		CustomMoves: 0,	DefaultCharacter: defaultChar, 							HeadOffsetY: 16, 	LivesX:	0,	LivesY: 0,	LivesAnim: 82);
		newCharacterVariable.add(Anim: CharAnim::HEAD_TAILS,	Head: CharAnim::TAILS,		Set: -1,			Name: "TailsHead",	HasFile: false,		CustomMoves: 0,	DefaultCharacter: defaultChar, 							HeadOffsetY: -16, 	LivesX:	2,	LivesY: 25,LivesAnim: 35);
		newCharacterVariable.add(Anim: CharAnim::HEAD_EGGMAN,	Head: CharAnim::SONIC,		Set: -1,			Name: "Eggman",	HasFile: false,		CustomMoves: 1,	DefaultCharacter: CHAR::BIRD2, 							HeadOffsetY: 8, 	LivesX:	0,	LivesY: 0,	LivesAnim: 83);
		newCharacterVariable.add(Anim: CharAnim::HEAD_DEVAN,	Head: -1,					Set: -1,			Name: "DevanHead",	HasFile: false,		CustomMoves: 0,	DefaultCharacter: defaultChar, 							HeadOffsetY: -24, 	LivesX:	0,	LivesY: 40,LivesAnim:  1);
		newCharacterVariable.add(Anim: CharAnim::HEAD_YOSHI,	Head: CharAnim::YOSHI,		Set: -1,			Name: "YoshiHead",	HasFile: false,		CustomMoves: 0,	DefaultCharacter: defaultChar, 							HeadOffsetY: -24, 	LivesX:	7,	LivesY: 25,LivesAnim: 82);
		newCharacterVariable.add(Anim: CharAnim::EVA,			Head: -1,					Set: -1,			Name: "Eva",		HasFile: false,		CustomMoves: 1,	DefaultCharacter: defaultChar, 							HeadOffsetY: 0, 	LivesX:	20,	LivesY: 30,LivesAnim:  1);
	}

	class SilentSoundboard 
	{
		SOUND::Sample OriginalSound;
		int AnimInd = -1; //trigger animation index
		dictionary CharSound;
		array<string> CharSoundArr;
		int SecondOption;
		string Silent;
		
		void add(SOUND::Sample OriginalSound, int AnimInd, dictionary CharSound, string Silent, int SecondOption = -1)
		{
			this.OriginalSound = OriginalSound;
			this.AnimInd = AnimInd;
			this.CharSound = CharSound;
			this.SecondOption = SecondOption;
			this.Silent = Silent;

			// cache: prepare integer array lookup (32 slots for chars)
			this.CharSoundArr.resize(32);
			for(int c = 0; c < 32; c++) this.CharSoundArr[c] = "";
			
			silentSounds.insertLast(this);
		}
	}
	array<SilentSoundboard> silentSounds;
	// cache last loaded filename per SOUND::Sample id to avoid repeated jjSampleLoad
	array<string> lastLoadedSample(256, "");

	void initializeSilentSoundboard()
	{
		SilentSoundboard newSound;
		newSound.add(OriginalSound: SOUND::SPAZSOUNDS_AUTSCH1, 	AnimInd: 29, SecondOption: 1,	Silent: "sfx_63_12_silent.wav",	CharSound: {{CharAnim::SPAZ+"", "sfx_92_0"}, 	{CharAnim::LORI1+"","sfx_63_1"}, 	 	{CharAnim::MARIO+"", "smbpipe"}, 				{CharAnim::SONIC+"", "sonic_rings_drop"}, 	{CharAnim::TAILS+"", "sonic_rings_drop"},	{CharAnim::DEVAN+"", ""}, 	{CharAnim::YOSHI+"", "YOSHI_AUTOSCH1 [Hurt]"}, 			{CharAnim::EVA+"", ""}});
		newSound.add(OriginalSound: SOUND::SPAZSOUNDS_AUTSCH2, 	AnimInd: 29, SecondOption: 0,	Silent: "sfx_63_12_silent.wav",	CharSound: {{CharAnim::SPAZ+"", "sfx_92_1"}, 	{CharAnim::LORI1+"","sfx_63_6"}, 	 	{CharAnim::MARIO+"", "smbpipe"}, 				{CharAnim::SONIC+"", "sonic_rings_drop"}, 	{CharAnim::TAILS+"", "sonic_rings_drop"}, 	{CharAnim::DEVAN+"", ""}, 	{CharAnim::YOSHI+"", "YOSHI_AUTOSCH2 [Hurt]"}, 			{CharAnim::EVA+"", ""}});
		newSound.add(OriginalSound: SOUND::SPAZSOUNDS_BIRDSIT, 	AnimInd: 79, SecondOption:-1,	Silent: "sfx_63_12_silent.wav",	CharSound: {{CharAnim::SPAZ+"", "sfx_92_2"}, 	{CharAnim::LORI1+"",""}, 	 			{CharAnim::MARIO+"", "smbpowerup"}, 			{CharAnim::SONIC+"", "sonic_extra_life"}, 	{CharAnim::TAILS+"", "sonic_extra_life"}, 	{CharAnim::DEVAN+"", ""}, 	{CharAnim::YOSHI+"", ""}, 								{CharAnim::EVA+"", ""}});
		newSound.add(OriginalSound: SOUND::SPAZSOUNDS_BURP, 	AnimInd:  4, SecondOption:-1,	Silent: "sfx_63_12_silent.wav",	CharSound: {{CharAnim::SPAZ+"", "sfx_92_3"}, 	{CharAnim::LORI1+"","sfx_63_9"}, 	 	{CharAnim::MARIO+"", "smbpowerup"}, 			{CharAnim::SONIC+"", "sonic_bumper"}, 		{CharAnim::TAILS+"", "sonic_bumper"}, 		{CharAnim::DEVAN+"", ""}, 	{CharAnim::YOSHI+"", ""}, 								{CharAnim::EVA+"", ""}});
		newSound.add(OriginalSound: SOUND::SPAZSOUNDS_CHIRP, 	AnimInd: 78, SecondOption:-1,	Silent: "sfx_63_12_silent.wav",	CharSound: {{CharAnim::SPAZ+"", "sfx_92_4"}, 	{CharAnim::LORI1+"","sfx_63_10"}, 	{CharAnim::MARIO+"", "smbpowerupappears"}, 	{CharAnim::SONIC+"", "sonic_slow"}, 			{CharAnim::TAILS+"", "sonic_sega"}, 			{CharAnim::DEVAN+"", ""}, 	{CharAnim::YOSHI+"", ""}, 								{CharAnim::EVA+"", ""}});
		newSound.add(OriginalSound: SOUND::SPAZSOUNDS_EATBIRD, 	AnimInd: 80, SecondOption:-1,	Silent: "sfx_63_12_silent.wav",	CharSound: {{CharAnim::SPAZ+"", "sfx_92_5"}, 	{CharAnim::LORI1+"","sfx_63_11"}, 	{CharAnim::MARIO+"", "smbpipe"}, 				{CharAnim::SONIC+"", "sonic_sega"}, 			{CharAnim::TAILS+"", "sonic_sega"}, 			{CharAnim::DEVAN+"", ""}, 	{CharAnim::YOSHI+"", "YOSHI_EATBIRD [MLEM]"}, 			{CharAnim::EVA+"", ""}});
		newSound.add(OriginalSound: SOUND::SPAZSOUNDS_HAHAHA, 	AnimInd: -1, SecondOption:-1,	Silent: "sfx_63_12_silent.wav",	CharSound: {{CharAnim::SPAZ+"", "sfx_92_6"}, 	{CharAnim::LORI1+"",""}, 				{CharAnim::MARIO+"", "sm64mariohoo"}, 		{CharAnim::SONIC+"", "sonic_ring"}, 			{CharAnim::TAILS+"", "sonic_ring"}, 			{CharAnim::DEVAN+"", ""}, 	{CharAnim::YOSHI+"", "YOSHI_HAHAHA"}, 					{CharAnim::EVA+"", ""}});
		newSound.add(OriginalSound: SOUND::SPAZSOUNDS_HAHAHA2, 	AnimInd: -1, SecondOption:-1,	Silent: "sfx_63_12_silent.wav",	CharSound: {{CharAnim::SPAZ+"", "sfx_92_7"}, 	{CharAnim::LORI1+"",""}, 				{CharAnim::MARIO+"", "sm64mariohoohoo"}, 	{CharAnim::SONIC+"", "sonic_chaos_emerald"},	{CharAnim::TAILS+"", "sonic_chaos_emerald"},	{CharAnim::DEVAN+"", ""}, 	{CharAnim::YOSHI+"", "YOSHI_HAHAHA2"}, 					{CharAnim::EVA+"", ""}});
		newSound.add(OriginalSound: SOUND::SPAZSOUNDS_HAPPY, 	AnimInd: 31, SecondOption:10,	Silent: "sfx_63_8_silent.wav",	CharSound: {{CharAnim::SPAZ+"", "sfx_92_8"}, 	{CharAnim::LORI1+"",""}, 				{CharAnim::MARIO+"", "sm64mariookeydokey"}, 	{CharAnim::SONIC+"", "sonic_turbo_spin"}, 	{CharAnim::TAILS+"", "sonic_turbo_spin"}, 	{CharAnim::DEVAN+"", ""}, 	{CharAnim::YOSHI+"", "YOSHI_HAPPY [SuperCarrot]"},		{CharAnim::EVA+"", ""}});
		newSound.add(OriginalSound: SOUND::SPAZSOUNDS_HIHI, 	AnimInd: -1, SecondOption:-1,	Silent: "sfx_63_12_silent.wav",	CharSound: {{CharAnim::SPAZ+"", "sfx_92_9"}, 	{CharAnim::LORI1+"",""}, 				{CharAnim::MARIO+"", "sm64mariowaha"}, 		{CharAnim::SONIC+"", "sonic_checkpoint"}, 	{CharAnim::TAILS+"", "sonic_checkpoint"}, 	{CharAnim::DEVAN+"", ""}, 	{CharAnim::YOSHI+"", ""}, 								{CharAnim::EVA+"", ""}});
		newSound.add(OriginalSound: SOUND::SPAZSOUNDS_HOHOHO1, 	AnimInd: 31, SecondOption: 8,	Silent: "sfx_63_10_silent.wav",	CharSound: {{CharAnim::SPAZ+"", "sfx_92_10"}, 	{CharAnim::LORI1+"",""}, 				{CharAnim::MARIO+"", "sm64mariowaha"}, 		{CharAnim::SONIC+"", "sonic_turbo_spin"}, 	{CharAnim::TAILS+"", "sonic_turbo_spin"}, 	{CharAnim::DEVAN+"", ""}, 	{CharAnim::YOSHI+"", ""}, 								{CharAnim::EVA+"", ""}});
		newSound.add(OriginalSound: SOUND::SPAZSOUNDS_HOOO, 	AnimInd: -1, SecondOption:-1,	Silent: "sfx_63_12_silent.wav",	CharSound: {{CharAnim::SPAZ+"", "sfx_92_11"}, 	{CharAnim::LORI1+"",""}, 				{CharAnim::MARIO+"", ""}, 					{CharAnim::SONIC+"", ""}, 					{CharAnim::TAILS+"", ""}, 					{CharAnim::DEVAN+"", ""}, 	{CharAnim::YOSHI+"", ""}, 								{CharAnim::EVA+"", ""}});
		newSound.add(OriginalSound: SOUND::SPAZSOUNDS_KARATE7, 	AnimInd:-64, SecondOption:13,	Silent: "sfx_63_13_silent.wav",	CharSound: {{CharAnim::SPAZ+"", "sfx_92_12"}, 	{CharAnim::LORI1+"","sfx_63_16"}, 	{CharAnim::MARIO+"", "sm64marioyahoo"}, 		{CharAnim::SONIC+"", "sonic_spindash"}, 		{CharAnim::TAILS+"", "sonic_spindash"}, 		{CharAnim::DEVAN+"", ""}, 	{CharAnim::YOSHI+"", "YOSHI_KARATE7 [FlutterJump]"}, 	{CharAnim::EVA+"", "sfx_38_3"}});
		newSound.add(OriginalSound: SOUND::SPAZSOUNDS_KARATE8, 	AnimInd:-64, SecondOption:12,	Silent: "sfx_63_12_silent.wav",	CharSound: {{CharAnim::SPAZ+"", "sfx_92_13"}, 	{CharAnim::LORI1+"","sfx_63_17"}, 	{CharAnim::MARIO+"", "sm64marioyippee"}, 	{CharAnim::SONIC+"", "sonic_spindash"}, 		{CharAnim::TAILS+"", "sonic_spindash"}, 		{CharAnim::DEVAN+"", ""}, 	{CharAnim::YOSHI+"", "YOSHI_KARATE8 [Angry]"}, 			{CharAnim::EVA+"", "sfx_38_0"}});
		newSound.add(OriginalSound: SOUND::SPAZSOUNDS_OHOH, 	AnimInd: 53, SecondOption:-1,	Silent: "sfx_63_12_silent.wav",	CharSound: {{CharAnim::SPAZ+"", "sfx_92_14"}, 	{CharAnim::LORI1+"",""}, 				{CharAnim::MARIO+"", "sm64mariohoohoo"}, 	{CharAnim::SONIC+"", "sonic_modern_jump"}, 	{CharAnim::TAILS+"", "sonic_modern_jump"}, 	{CharAnim::DEVAN+"", ""}, 	{CharAnim::YOSHI+"", "YOSHI_OHOH [Happy Yoshi]"}, 		{CharAnim::EVA+"", ""}});
		newSound.add(OriginalSound: SOUND::SPAZSOUNDS_OOOH, 	AnimInd: -2, SecondOption:-1,	Silent: "sfx_63_12_silent.wav",	CharSound: {{CharAnim::SPAZ+"", "sfx_92_15"}, 	{CharAnim::LORI1+"","sfx_63_15"}, 	{CharAnim::MARIO+"", "sm64mariohoohoo"}, 	{CharAnim::SONIC+"", "sonic_modern_jump"}, 	{CharAnim::TAILS+"", "sonic_modern_jump"}, 	{CharAnim::DEVAN+"", ""}, 	{CharAnim::YOSHI+"", "YOSHI_OOOH [Shield Hurt]"}, 		{CharAnim::EVA+"", ""}});
		newSound.add(OriginalSound: SOUND::SPAZSOUNDS_WOOHOO, 	AnimInd: 40, SecondOption:-1,	Silent: "sfx_63_12_silent.wav",	CharSound: {{CharAnim::SPAZ+"", "sfx_92_16"}, 	{CharAnim::LORI1+"","sfx_63_18"}, 	{CharAnim::MARIO+"", "sm64mariohoo"}, 		{CharAnim::SONIC+"", "sonic_woo"}, 			{CharAnim::TAILS+"", "sonic_woo"}, 			{CharAnim::DEVAN+"", ""}, 	{CharAnim::YOSHI+"", "YOSHI_WOOHOO [Wow]"}, 				{CharAnim::EVA+"", ""}});
		newSound.add(OriginalSound: SOUND::SPAZSOUNDS_YAHOO, 	AnimInd:-12, SecondOption:-1,	Silent: "sfx_63_12_silent.wav",	CharSound: {{CharAnim::SPAZ+"", "sfx_92_17"}, 	{CharAnim::LORI1+"","sfx_63_12"}, 	{CharAnim::MARIO+"", "sm64mariodoh"}, 		{CharAnim::SONIC+"", "sonic_ring"}, 			{CharAnim::TAILS+"", "sonic_ring"}, 			{CharAnim::DEVAN+"", ""}, 	{CharAnim::YOSHI+"", "YOSHI_YAHOO [ButtStomp]"}, 		{CharAnim::EVA+"", ""}});
		newSound.add(OriginalSound: SOUND::SPAZSOUNDS_YAHOO2, 	AnimInd: 60, SecondOption:-1,	Silent: "sfx_63_12_silent.wav",	CharSound: {{CharAnim::SPAZ+"", "sfx_92_18"}, 	{CharAnim::LORI1+"",""}, 				{CharAnim::MARIO+"", "sm64mariohaha"}, 		{CharAnim::SONIC+"", "sonic_spring"}, 		{CharAnim::TAILS+"", "sonic_spring"}, 		{CharAnim::DEVAN+"", ""}, 	{CharAnim::YOSHI+"", "YOSHI_YAHOO2 [Spring]"}, 			{CharAnim::EVA+"", ""}});
		
		newSound.add(OriginalSound: SOUND::COMMON_JUMP, 		AnimInd: 52, SecondOption:-1, Silent: "sfx_22_62.wav",			CharSound: {{CharAnim::SPAZ+"", "sfx_22_62"}, 	{CharAnim::LORI1+"","sfx_63_13"}, 	{CharAnim::MARIO+"", "smbjumpsuper"}, 		{CharAnim::SONIC+"", "sonic_jump"}, 			{CharAnim::TAILS+"", "sonic_jump"}, 			{CharAnim::DEVAN+"", ""}, 	{CharAnim::YOSHI+"", "YOSHI_JUMP"}, 						{CharAnim::EVA+"", "sfx_22_62"}});
		newSound.add(OriginalSound: SOUND::COMMON_UP,		 	AnimInd: -3, SecondOption:-1, Silent: "sfx_22_114.wav",			CharSound: {{CharAnim::SPAZ+"", "sfx_22_114"},	{CharAnim::LORI1+"","sfx_63_14"}, 	{CharAnim::MARIO+"", "smbjumpsmall"}, 		{CharAnim::SONIC+"", "sonic_jump"}, 			{CharAnim::TAILS+"", "sonic_jump"}, 			{CharAnim::DEVAN+"", ""}, 	{CharAnim::YOSHI+"", "YOSHI_Jump_Another"}, 				{CharAnim::EVA+"", "sfx_22_114"}});
		newSound.add(OriginalSound: SOUND::ENDTUNESPAZ_TUNE,	AnimInd: -1, SecondOption:-1, Silent: "TUNE_Spaz.wav",			CharSound: {{CharAnim::SPAZ+"", "TUNE_Spaz"},	{CharAnim::LORI1+"","TUNE_Lori"}, 	{CharAnim::MARIO+"", "TUNE_SuperMario"}, 	{CharAnim::SONIC+"", "TUNE_SonicHedgehog"}, 	{CharAnim::TAILS+"", "TUNE_SonicHedgehog"}, 	{CharAnim::DEVAN+"", ""}, 	{CharAnim::YOSHI+"", "TUNE_Yoshisaur"}, 					{CharAnim::EVA+"", "TUNE_Spaz"}});

		// Cache: Convert legacy dictionaries into fast integer-indexed arrays for runtime
		for(uint s = 0; s < silentSounds.length; s++)
		{
			for(int c = 0; c < 32; c++)
			{
				string name = "";
				silentSounds[s].CharSound.get(c + "", name);
				silentSounds[s].CharSoundArr[c] = name;
			}
		}
	}

	//----------------------------------------------------------------------------------------------------
	/// <summary>
	/// charactersMin.j2a loading and render-index region. CharactersMinAnim.asc maps original animation IDs to frame ranges inside packed merged animations.
	/// </summary>
	bool charactersMinAvailable = false;
	const int CHARACTERS_MIN_MAX_FRAMES_PER_ANIMATION = -1; // -1 = no limit, 100 = indexes 0-99, 200 = indexes 0-199
	array<array<int>> charactersMinAnimMerged;
	array<array<int>> charactersMinAnimStart;
	array<array<int>> charactersMinAnimCount;
	array<int> charactersMinMergedAnimCount;
	array<int> charactersMinOriginalAnimCount;
	array<int> charactersMinSetByAnimSet(4096, -1);

	void InitializeCharactersMinTables()
	{
		charactersMinAnimStart.resize(10);
		charactersMinAnimMerged.resize(10);
		charactersMinAnimCount.resize(10);
		charactersMinMergedAnimCount.resize(10);
		charactersMinOriginalAnimCount.resize(10);
		for(uint i = 0; i < 10; i++)
		{
			charactersMinMergedAnimCount[i] = 0;
			charactersMinOriginalAnimCount[i] = 0;
			charactersMinAnimMerged[i].resize(128);
			charactersMinAnimStart[i].resize(128);
			charactersMinAnimCount[i].resize(128);
			for(uint j = 0; j < 128; j++) { charactersMinAnimMerged[i][j] = -1; charactersMinAnimStart[i][j] = -1; charactersMinAnimCount[i][j] = 0; }
		}
	}

	bool LoadCharactersMinAnimIndexes()
	{
		InitializeCharactersMinTables();

		// CharactersMinAnim.asc replaces the text table. Preserve the existing
		// table shape and max-frame rule used by the rest of this script.
		for(int sourceSet = 0; sourceSet < int(charactersMinAnimStart.length); sourceSet++)
		{
			int animCount = CharactersMinAnim::AnimCount(sourceSet);
			if(animCount > int(charactersMinAnimStart[sourceSet].length))
				animCount = int(charactersMinAnimStart[sourceSet].length);

			for(int anim = 0; anim < animCount; anim++)
			{
				int mergedAnim = CharactersMinAnim::MergedAnim(sourceSet, anim);
				int startFrame = CharactersMinAnim::StartFrame(sourceSet, anim);
				int frameCount = CharactersMinAnim::FrameCount(sourceSet, anim);

				if(CHARACTERS_MIN_MAX_FRAMES_PER_ANIMATION >= 0
				&& sourceSet != 0
				&& sourceSet != 7
				&& startFrame + frameCount > CHARACTERS_MIN_MAX_FRAMES_PER_ANIMATION)
					continue;

				charactersMinAnimMerged[sourceSet][anim] = mergedAnim;
				charactersMinAnimStart[sourceSet][anim] = startFrame;
				charactersMinAnimCount[sourceSet][anim] = frameCount;

				if(mergedAnim + 1 > charactersMinMergedAnimCount[sourceSet])
					charactersMinMergedAnimCount[sourceSet] = mergedAnim + 1;
				if(anim + 1 > charactersMinOriginalAnimCount[sourceSet])
					charactersMinOriginalAnimCount[sourceSet] = anim + 1;
			}
		}

		return true;
	}

	void LoadCharactersMinAnimationIndexes()
	{
		charactersMinAvailable = LoadCharactersMinAnimIndexes();
	}

	void RegisterCharactersMinAnimSet(int animSet, int sourceSet)
	{
		if(sourceSet == 0 || sourceSet == 7) return;
		if(animSet >= int(charactersMinSetByAnimSet.length))
		{
			uint oldLength = charactersMinSetByAnimSet.length;
			charactersMinSetByAnimSet.resize(animSet + 1);
			for(uint i = oldLength; i < charactersMinSetByAnimSet.length; i++) charactersMinSetByAnimSet[i] = -1;
		}
		if(animSet >= 0 && animSet < int(charactersMinSetByAnimSet.length)) charactersMinSetByAnimSet[animSet] = sourceSet;
	}

	int CharactersMinSourceSetForAnimSet(int animSet)
	{
		if(animSet >= 0 && animSet < int(charactersMinSetByAnimSet.length))
		{
			int sourceSet = charactersMinSetByAnimSet[animSet];
			if(sourceSet >= 0) return sourceSet;
		}
		for(uint i = 0; i < chars.length; i++)
		{
			if(chars[i].Set >= 0 && ANIM::CUSTOM[chars[i].Set] == animSet && chars[i].CharactersMinSourceSet >= 0)
				return chars[i].CharactersMinSourceSet;
		}
		return -1;
	}


	int spazBackupSet = 0;
	void EnsureNativeMorphAnimationsLoaded()
	{
		if(jjAnimSets[ANIM::FROG].firstAnim == 0)
			jjAnimSets[ANIM::FROG].load();
		if(jjAnimSets[ANIM::BIRD].firstAnim == 0)
			jjAnimSets[ANIM::BIRD].load();
	}

	void loadCustomCharacterAnimations()
	{
		if(!doneSetup && chars.length > 0)
		{
			jjAnimSets[ANIM::DEVILDEVAN].load();
			jjAnimSets[ANIM::DEVAN].load();
			jjAnimSets[ANIM::FLAG].load();
			jjAnimSets[ANIM::EVA].load();
			jjAnimSets[ANIM::WITCH].load();
			
			for(uint i=0; i<chars.length; i++)
			{
				if(chars[i].Set != -1)
				{
					jjANIMSET@ animToLoad = jjAnimSets[ANIM::CUSTOM[chars[i].Set]];
					int sourceSet = CharactersMinSourceForCharSlot(i);
					if(sourceSet < 0) continue;
					chars[i].CharactersMinSourceSet = sourceSet;
					animToLoad.load(sourceSet, "charactersMin.j2a");
					if(charactersMinAvailable) RegisterCharactersMinAnimSet(ANIM::CUSTOM[chars[i].Set], sourceSet);
				}
			}
		}
	}

	void setHeadAnims() //IMPLEMENTATION: theoretically this could be transfered to the CharacterVariables table
	{
		for(int char = CharAnim::HEAD_JAZZ; char <= CharAnim::HEAD_YOSHI; char++)
		{
			if(uint(char) < chars.length)
			{
				switch(char)
				{
					case CharAnim::HEAD_MARIO:
						chars[char].HeadAnimSet = ANIM::CUSTOM[chars[CharAnim::MARIO].Set];
						break;
					case CharAnim::HEAD_SONIC:
					case CharAnim::HEAD_EGGMAN:
						chars[char].HeadAnimSet = ANIM::CUSTOM[chars[CharAnim::SONIC].Set];
						break;
					case CharAnim::HEAD_TAILS:
						chars[char].HeadAnimSet = ANIM::CUSTOM[chars[CharAnim::TAILS].Set];
						break;
					case CharAnim::HEAD_DEVAN:
						chars[char].HeadAnimSet = ANIM::CUSTOM[chars[CharAnim::DEVAN].Set];
						break;
					case CharAnim::HEAD_YOSHI:
						chars[char].HeadAnimSet = ANIM::CUSTOM[chars[CharAnim::YOSHI].Set];
						break;
					case CharAnim::HEAD_LORI:
						if(!jjIsTSF) chars[char].HeadAnimSet = ANIM::CUSTOM[chars[CharAnim::LORI1].Set];
						else chars[char].HeadAnimSet = ANIM::FACES;
						break;
					default:
						chars[char].HeadAnimSet = ANIM::FACES;
						break;
				}
			}
		}
	}

	array<int> excludedAnims = {4, 61};
	array<int> testExcludedAnims = {0, 1, 17, 60};
	void readFiles()
	{
		for(uint i=0; i<chars.length; i++)
		{
			if(chars[i].File != "")
			{
				jjSTREAM file(chars[i].File);
				while(!file.isEmpty())
				{
					string str = "";
					file.getLine(str);
					array<string> string_parts = str.split(" ");
					int j = parseInt(string_parts[0]);
					if(chars[i].File != "Yoshi.asdat" && chars[i].File != "JJ1.asdat" && chars[i].File != "Tails.asdat"){
						chars[i].animRel[j] = parseInt(string_parts[1]);
					}
					if(chars[i].File != "Yoshi.asdat" && chars[i].File != "JJ1.asdat" && chars[i].File != "Tails.asdat")
					{
						chars[i].adjustX[j] = parseInt(string_parts[2]);
						chars[i].adjustY[j] = parseInt(string_parts[3]);
						chars[i].sizeX[j] = parseFloat(string_parts[4]);
						chars[i].sizeY[j] = parseFloat(string_parts[5]);
					}
					/*//Testing exceptions:
					if(chars[i].File == "Tails.asdat" && chars[i].animRel[j] == 36){
						chars[i].adjustX[j] = -25;
						chars[i].adjustY[j] = -25;
					}*/
				}
				
				array<int> excludedAnimsCustom(excludedAnims.length, 0);
				for(uint k = 0; k < excludedAnims.length; k++)
				{
					excludedAnimsCustom[k] = chars[i].animRel[excludedAnims[k]];
				}
				
				for(uint j = 0; j < chars[i].animRel.length; j++)
				{
					if(excludedAnimsCustom.find(chars[i].animRel[j]) > 0)
					{
						chars[i].adjustX[j] = 0;
						chars[i].adjustY[j] = 0;
						chars[i].sizeX[j] = 1;
						chars[i].sizeY[j] = 1;
					}
				}
			}
		}
		/*//Print animRel:
		string line = "";
		for(uint i=0; i<90; i++){
			line += chars[CharAnim::TAILS].animRel[i]+", ";
		}
		jjAlert("line "+line);*/
	}

	void ExportAnimationsToTxt()
	{
		int exportChar = CharAnim::LORI1;
		for(uint i=0; i<=80; i++)
		{
			for(uint j=0; j<jjAnimations[jjAnimSets[ANIM::CUSTOM[chars[exportChar].Set]] + i].frameCount; j++)
			{
				jjANIMFRAME@ frame = jjAnimFrames[jjAnimations[jjAnimSets[ANIM::CUSTOM[chars[exportChar].Set]] + i] + j];
				if(frame.hotSpotX == 0 && frame.hotSpotX == 0 && frame.coldSpotX == 0 && frame.coldSpotY == 0 && frame.gunSpotX == 0 && frame.gunSpotY == 0)
				{
					ExportFrameToTxt(frame, "_File"+chars[exportChar].Name+"-"+i+"---"+j
						+","+frame.hotSpotX+","+frame.hotSpotX+","+frame.coldSpotX+","+frame.coldSpotY+","+frame.gunSpotX+","+frame.gunSpotY);
				}
				else
				{
					int spotX = chars[exportChar].adjustX[i];
					int spotY = chars[exportChar].adjustY[i];
					ExportFrameToTxt(frame, "_File"+chars[exportChar].Name+"-"+i+"---"+j
						+","+spotX+","+spotY+","+spotX+","+spotY+","+spotX+","+spotY);
				}
			}
		}
	}

	void ExportFrameToTxt(jjANIMFRAME@ frame, string Filename)
	{
		if(jjIsServer)
		{
			jjSTREAM save;
			jjPIXELMAP image(frame);
			string line = (image.width)+" "+(image.height);
			save.write(line+"\n");
			for (int x = image.width - 1; x >= 0; --x)
			{
				line = "";
				for (int y = image.height - 1; y >= 0; --y)
				{
					const uint8 color = image[x,y];
					line += color+" ";
				}
				save.write(line+"\n");
			}
			save.save(Filename);
		}
	}

	array<float> lastKnownXPos(4, -1);
	array<float> lastKnownYPos(4, -1);
	array<bool> lastRoast(4, false);
	array<int> lastRoastTicks(4, 0);
	array<bool> wasSpectating(4, false);
	class SpazBoots
	{
		float xPos;
		float yPos;
		int creatorID;
		int char;
		int age = 0;
		bool render = false;
		
		void Add(float xPos, float yPos, int creatorID, int char)
		{
			this.xPos = xPos;
			this.yPos = yPos;
			this.creatorID = creatorID;
			this.char = char;
			
			boots.insertLast(this);
		}
	}
	array<SpazBoots> boots;
	void SpazBoot()
	{
		uint k = 0;
		while(k < boots.length)
		{
			if(boots[k].age >= 700)
			{
				boots.removeAt(k);
				k--;
			}
			k++;
		}
		
		for(uint i=0; i<boots.length; i++)
		{
			boots[i].age++;
			int char = CHANGE_SPAZ_BOOT_CHARACTER ? characters[boots[i].creatorID] : boots[i].char;
			//get character again, since you reassigned boots[i].creatorID
			if(0 <= boots[i].creatorID && boots[i].creatorID < 32)
			{
				jjPLAYER@ p = jjPlayers[boots[i].creatorID];
				int indPlayer = PlayerRabbitAnimRel(p);
				if(indPlayer != 4 && boots[i].age >= 100) boots[i].render = true;
				if(boots[i].render)
				{
					if(defaultCharCount < char && char < int(chars.length) && char != CharAnim::JJ1
						&& !isPlayerHead(char) && !isCustomHead(char) && char != CharAnim::WITCH && char != CharAnim::EVA)
					{
						int ind = 3;
						int grav = 1;
						int animRel = jjIsTSF ? ind : 2;
						bool hasFile = chars[char].File == "";
						DrawParams draw;
						draw.add(
							xPos: boots[i].xPos + (hasFile ? 0 : chars[char].adjustX[ind]*p.direction),
							yPos: boots[i].yPos + (hasFile ? 0 : chars[char].adjustY[ind]*grav),
							animSet: ANIM::CUSTOM[chars[char].Set],
							anim: (hasFile && chars[char].animRel.length <= 1) ? animRel : chars[char].animRel[ind],
							frame: jjGameTicks/10,
							sizeX: hasFile ? 1*p.direction : 1*chars[char].sizeX[ind]*p.direction,
							sizeY: hasFile ? 1*grav : 1*chars[char].sizeY[ind]*grav,
							spriteMode: SPRITE::PLAYER,
							param: boots[i].creatorID,
							grav: grav,
							animation: jjPlayers[boots[i].creatorID].charCurr
						);
						if(char == CharAnim::DEVAN)
						{
							draw.animSet = ANIM::DEVILDEVAN;
							draw.charactersMinSourceSet = -1;
							draw.anim = 1;
						}
						jjDrawSprite(boots[i].xPos, boots[i].yPos, draw.animSet, draw.anim, jjGameTicks/10, 0, SPRITE::PLAYER, boots[i].creatorID);
					}
					else
					{
						if(char == CharAnim::JAZZ || char == CharAnim::JJ1){
							jjDrawSprite(boots[i].xPos, boots[i].yPos, ANIM::JAZZ, 3, jjGameTicks/10, 0, SPRITE::PLAYER, boots[i].creatorID);
						}else if(char == CharAnim::LORI || char == CharAnim::LORI1){
							jjDrawSprite(boots[i].xPos, boots[i].yPos, ANIM::LORI, 3, jjGameTicks/10, 0, SPRITE::PLAYER, boots[i].creatorID);
						}else if(char == CharAnim::BIRD || char == CharAnim::BIRD2){ //5 explode, 19 corpse - bird
							jjDrawSprite(boots[i].xPos, boots[i].yPos, ANIM::BIRD, 19, jjGameTicks/10, 0, SPRITE::PLAYER, boots[i].creatorID);
						}else{// if(char == CharAnim::SPAZ) or others
							jjDrawSprite(boots[i].xPos, boots[i].yPos, ANIM::SPAZ, 3, jjGameTicks/10, 0, SPRITE::PLAYER, boots[i].creatorID);
						}
					}
				}
			}
		}
		for(int i=0; i<jjLocalPlayerCount; i++)
		{
			if(lastRoast[i])
			{
				lastRoast[i] = false;
				SpazBoots boot;
				boot.Add(lastKnownXPos[i], lastKnownYPos[i], jjLocalPlayers[i].playerID, characters[jjLocalPlayers[i].playerID]);
				sendSpazBootStream(lastKnownXPos[i], lastKnownYPos[i], jjLocalPlayers[i].playerID);
			}
		
			lastKnownXPos[i] = jjLocalPlayers[i].xPos;
			lastKnownYPos[i] = jjLocalPlayers[i].yPos;
		}
		
		for(int j=0; j<jjObjectCount; j++)
		{
			jjOBJ@ obj = jjObjects[j];
			if(obj.eventID == OBJECT::EXPLOSION && obj.behavior == BEHAVIOR::CORPSE)
			{
				obj.delete();
			}
		}
	}

	void sendSpazBootStream(float xPos, float yPos, int creatorID, int fromClientID = 0)
	{
		jjSTREAM myStream;
		myStream.push("!spazBoot "+xPos+" "+yPos+" "+creatorID);
		if(fromClientID == 0) jjSendPacket(myStream);
		else jjSendPacket(myStream, -fromClientID);
	}

	class LoriBlaster : jjBEHAVIORINTERFACE {
		void onBehave(jjOBJ@ obj) {
			obj.behave(BEHAVIOR::MONITOR, true);
		}
		void onDraw(jjOBJ@ obj) { //usually also called once per tick
			for(int i=0; i<32; i++){
				if(jjPlayers[i].isLocal && characters[i] == CharAnim::LORI1){
					DrawCharacterLayer(obj.xPos, obj.yPos, ANIM::CUSTOM[chars[CharAnim::LORI2].Set], CharactersMinSourceForCharSlot(CharAnim::LORI2), 83, jjGameTicks/10, obj.direction, 1, SPRITE::NORMAL, 0, 4, 4, i);
				}else if(jjPlayers[i].isLocal){
					jjDrawSpriteFromCurFrame(obj.xPos, obj.yPos, obj.curFrame, obj.direction, SPRITE::NORMAL, 0, 4, 4, i);
				}
			}
		}
	}
	float getY(jjOBJ@ obj)
	{
		int arg = (((obj.objectID << 3) + ::jjGameTicks) << (obj.yPos > ::jjWaterLevel ? 1 : 4)) + (int(obj.xPos) << 4);
		return obj.yPos + jjSin(arg) * 4.f;
	}

	float AirboardWobbleYOffset(jjPLAYER@ play)
	{
		if(play.fly != FLIGHT::AIRBOARD)
			return 0;
		int arg = (((play.playerID << 3) + ::jjGameTicks) << (play.yPos > ::jjWaterLevel ? 1 : 4)) + (int(play.xPos) << 4);
		return jjSin(arg) * 4.f;
	}

	bool IsNativeAirboardAnimation(jjPLAYER@ play)
	{
		if(play.fly != FLIGHT::AIRBOARD)
			return false;
		return PlayerRabbitAnimRel(play) == RABBIT::AIRBOARD;
	}

	int PlayerNativeAnimRel(jjPLAYER@ play)
	{
		return play.curAnim - jjAnimSets[play.setID].firstAnim;
	}

	int PlayerRabbitAnimRel(jjPLAYER@ play)
	{
		int animRel = PlayerNativeAnimRel(play);
		return jjIsTSF ? animRel : revertIndex(animRel);
	}

	int PlayerRabbitAnimRelForSet(jjPLAYER@ play, int animSet)
	{
		int animRel = play.curAnim - jjAnimSets[animSet].firstAnim;
		return jjIsTSF ? animRel : revertIndex(animRel);
	}
	class LoriFastfire : jjBEHAVIORINTERFACE {
		void onBehave(jjOBJ@ obj) {
			obj.behave(BEHAVIOR::PICKUP, false);
		}
		void onDraw(jjOBJ@ obj) { //usually also called once per tick
			float yPos = getY(obj);
			for(int i=0; i<jjLocalPlayerCount; i++){
				jjPLAYER@ play = jjLocalPlayers[i];
				if(characters[play.playerID] == CharAnim::LORI1){
					DrawCharacterLayer(obj.xPos, yPos, ANIM::CUSTOM[chars[CharAnim::LORI2].Set], CharactersMinSourceForCharSlot(CharAnim::LORI2), 82, jjGameTicks/10, obj.direction, 1, SPRITE::NORMAL, 0, 4, 4, play.playerID);
				}else{
					jjDrawSpriteFromCurFrame(obj.xPos, yPos, obj.curFrame, obj.direction, SPRITE::NORMAL, 0, 4, 4, play.playerID);
				}
			}
		}
	}

	void onLevelLoad()
	{
		if(jjIsServer) jjAlert("Type |||!morphMenu ||||| to enable/disable morphs!");
		jjAlert("Type |||!listMorphs ||||| to list all available morph commands!");

		EnsureNativeMorphAnimationsLoaded();
		jjCharacters[CHAR::FROG].canHurt = true;
		jjCharacters[CHAR::BIRD].canHurt = true;
		jjCharacters[CHAR::FROG].canRun = true;
		jjCharacters[CHAR::BIRD].canRun = true;
		
		jjObjectPresets[OBJECT::BLASTERPOWERUP].behavior = function(obj) { obj.behavior = LoriBlaster(); };
		jjObjectPresets[OBJECT::FASTFIRE].behavior = function(obj) { obj.behavior = LoriFastfire(); };
		jjObjectPresets[OBJECT::BLASTERBULLET].behavior = function(obj) { obj.behavior = DevanBlasterBullet(); };
		jjObjectPresets[OBJECT::BLASTERBULLETPU].behavior = function(obj) { obj.behavior = DevanBlasterBullet(); };
		
		jjSTREAM loadYoshiPreference(YoshiPreferenceFilename);
		loadYoshiPreference.pop(LOAD_BLUE_YOSHI);
		LoadHidePreference();
		LoadNamingSchemePreference();
		LoadTracePreference();
		FrogHunt::ResetForLevel();
	}

	void LoadHidePreference()
	{
		jjSTREAM load(HidePreferenceFilename);
		string line = "";
		if(!load.isEmpty())
		{
			load.getLine(line);
			array<string> parts = line.split(" ");
			if(parts.length >= 2 && jjRegexMatch(parts[0], "Hiding", true))
				CHARS_NEW_HIDING = parts[1] == "ON" || parts[1] == "1";
		}
	}

	void SaveHidePreference()
	{
		SaveHidePreferenceValue(CHARS_NEW_HIDING);
	}

	void SaveHidePreferenceValue(bool value)
	{
		jjSTREAM save;
		save.write("Hiding " + (value ? "ON" : "OFF") + "\n");
		save.save(HidePreferenceFilename);
	}

	void LoadNamingSchemePreference()
	{
		jjSTREAM load(NamingSchemePreferenceFilename);
		string line = "";
		if(!load.isEmpty())
		{
			load.getLine(line);
			array<string> parts = line.split(" ");
			if(parts.length >= 2 && jjRegexMatch(parts[0], "OldNamingScheme", true))
				OLD_NAMING_SCHEME = parts[1] == "ON" || parts[1] == "1";
		}
	}

	void SaveNamingSchemePreference()
	{
		jjSTREAM save;
		save.write("OldNamingScheme " + (OLD_NAMING_SCHEME ? "ON" : "OFF") + "\n");
		save.save(NamingSchemePreferenceFilename);
	}

	void LoadTracePreference()
	{
		jjSTREAM load(TracePreferenceFilename);
		string line = "";
		while(!load.isEmpty())
		{
			load.getLine(line);
			array<string> parts = line.split(" ");
			if(parts.length >= 2 && jjRegexMatch(parts[0], "^Trace$", true))
				TRACE_ENABLED = parts[1] == "ON" || parts[1] == "1";
			else if(parts.length >= 2 && jjRegexMatch(parts[0], "^TraceFeature$", true))
				TRACE_FEATURE_ENABLED = parts[1] == "ON" || parts[1] == "1";
			else if(parts.length >= 2 && jjRegexMatch(parts[0], "^TraceColor$", true))
			{
				int index = parseInt(parts[1]);
				if(0 <= index && index < int(TRACE_COLOR_NAMES.length))
				{
					TRACE_COLOR_INDEX = index;
					TRACE_COLOR_VALUE = TRACE_COLOR_VALUES[index];
				}
			}
			else if(parts.length >= 2 && jjRegexMatch(parts[0], "^TraceColorValue$", true))
			{
				int value = parseInt(parts[1]);
				if(0 <= value && value <= 255)
				{
					TRACE_COLOR_VALUE = value;
					TRACE_COLOR_INDEX = TraceColorIndexFromValue(value);
				}
			}
			else if(parts.length >= 2 && jjRegexMatch(parts[0], "^TraceStyle$", true))
			{
				int value = parseInt(parts[1]);
				if(TRACE_STYLE_NORMAL <= value && value <= TRACE_STYLE_CONST)
					TRACE_STYLE = value;
			}
			else if(parts.length >= 2 && jjRegexMatch(parts[0], "^TraceMode$", true))
			{
				int value = parseInt(parts[1]);
				if(TRACE_MODE_COLOR <= value && value <= TRACE_MODE_RANDOM)
					TRACE_MODE = value;
			}
			else if(parts.length >= 2 && jjRegexMatch(parts[0], "^HangLedges$", true))
				HANG_LEDGES_LOCAL_ENABLED = parts[1] == "ON" || parts[1] == "1";
		}
	}

	void SaveTracePreference()
	{
		jjSTREAM save;
		save.write("Trace " + (TRACE_ENABLED ? "ON" : "OFF") + "\n");
		save.write("TraceFeature " + (TRACE_FEATURE_ENABLED ? "ON" : "OFF") + "\n");
		save.write("TraceColor " + TRACE_COLOR_INDEX + "\n");
		save.write("TraceColorValue " + TRACE_COLOR_VALUE + "\n");
		save.write("TraceStyle " + TRACE_STYLE + "\n");
		save.write("TraceMode " + TRACE_MODE + "\n");
		save.write("HangLedges " + (HANG_LEDGES_LOCAL_ENABLED ? "ON" : "OFF") + "\n");
		save.save(TracePreferenceFilename);
	}

	bool doneSetup = false;
	array<int> origAnim(4, 0);
	void Setup()
	{
		//The order in which these methods are called has to be maintained
		EnsureNativeMorphAnimationsLoaded();
		LoadCharactersMinAnimationIndexes();
		loadSpazBackupAnimations();
		initializeCharacterVariables();
		SILENT_SOUND_SYSTEM = SILENT_SOUND_SYSTEM && jjLocalPlayerCount > 1;
		initializeSilentSoundboard();
		loadCustomCharacterAnimations();
		setHeadAnims();
		readFiles();
		//ExportAnimationsToTxt();
		if(jjIsServer) importSettings();
		for(int i=0; i<jjLocalPlayerCount; i++)
			ResetLocalCharStateSession(jjLocalPlayers[i].playerID);
		ApplyLocalTracePreference();
		TransmitSettings();
		for(int i=0; i<jjLocalPlayerCount; i++)
		{
			origAnim[i] = jjLocalPlayers[i].charCurr;
		}
		//RecolorYoshi();

		doneSetup = true;
	}

	array<int> characters(32);
	array<int> lastCharacter(32, 0);
	array<bool> forceFrogVisual(32, false);
	array<bool> forceFrogManual(32, false);
	array<bool> evaRevertPending(32, false);
	array<int> forceFrogAnimID(32, 2);
	array<int> forceFrogFrameID(32, 0);
	array<int> forceFrogDirection(32, 1);
	array<int> forceFrogLastSentAnimID(32, -1);
	array<int> forceFrogLastSentFrameID(32, -1);
	array<int> forceFrogLastSentDirection(32, 0);
	array<int> forceFrogLastSentTick(32, -9999);
	array<bool> traceEnabledForPlayer(32, false);
	array<int> traceColorForPlayer(32, 39);
	array<int> traceStyleForPlayer(32, TRACE_STYLE_NORMAL);
	array<int> traceModeForPlayer(32, TRACE_MODE_COLOR);
	array<int> lastTraceCanvasDrawTick(32, -1000);
	array<int> lastTraceFallbackDrawTick(32, -1000);
	const int CHAR_SIZE_TINY = 0;
	const int CHAR_SIZE_NORMAL = 1;
	const int CHAR_SIZE_GIANT = 2;
	array<int> charSizeMode(32, CHAR_SIZE_NORMAL);
	array<int> charStateSession(32, 0);
	array<int> charStateRevision(32, 0);
	array<int> receivedCharStateSession(32, -1);
	array<int> receivedCharStateRevision(32, -1);
	array<int> nativeMorphPendingUntil(32, 0);
	array<int> nativeMorphExpectedChar(32, -1);
	array<int> charStateResendUntil(32, 0);
	array<int> charStateNextResendTick(32, 0);
	// Tracks AIRBOARD flight created by this character script (currently Witch).
	// This lets us clear stale script flight when leaving the character without
	// cancelling a genuine JJ2 airboard that the player already had.
	array<bool> scriptForcedAirboard(32, false);
	int charStateSessionCounter = 0;

	bool ShouldAutoForceFrog(int char)
	{
		return FROG_FORCE_ENABLED && char == CharAnim::FROG;
	}

	void SetForceFrogVisual(int playerID, bool active, bool manual)
	{
		if(playerID < 0 || playerID >= 32) return;
		forceFrogVisual[playerID] = active;
		forceFrogManual[playerID] = active && manual;
		if(active) EnsureNativeMorphAnimationsLoaded();
	}

	void SyncCharacterAfterForcedFrogRevert(jjPLAYER@ play)
	{
		if(play is null || !play.isLocal || play.playerID < 0 || play.playerID >= 32 || play.charCurr == CHAR::FROG) return;

		int nativeChar = 0;
		if(!charDict.get(play.charCurr + '', nativeChar)) return;

		int playerID = play.playerID;
		if(lastCharacter[playerID] >= 0 && lastCharacter[playerID] < int(chars.length)
		&& chars[nativeChar].DefaultCharacter == chars[lastCharacter[playerID]].DefaultCharacter)
			characters[playerID] = chars[lastCharacter[playerID]].Anim;
		else
			characters[playerID] = chars[nativeChar].Anim;

		setCharFlight(play, characters[playerID]);
		ApplyFrogForceForCharacter(play, characters[playerID], true);
		TransmitFromChat(play);
	}

	void FinishNativeForcedFrogRevert(jjPLAYER@ play)
	{
		if(play is null || !play.isLocal || play.playerID < 0 || play.playerID >= 32) return;
		int playerID = play.playerID;
		if(play.charCurr == CHAR::FROG) return;
		if(!forceFrogVisual[playerID] && !forceFrogManual[playerID]) return;

		SetForceFrogVisual(playerID, false, false);
		play.spriteMode = SPRITE::PLAYER;
		SyncCharacterAfterForcedFrogRevert(play);
	}

	void RevertMorphFromEva(int playerID)
	{
		if(playerID < 0 || playerID >= 32) return;
		jjPLAYER@ play = jjPlayers[playerID];
		if(!play.isActive || !play.isLocal) return;

		bool wasForcedFrog = forceFrogVisual[playerID] || forceFrogManual[playerID];
		if(wasForcedFrog)
		{
			SetForceFrogVisual(playerID, false, false);
			play.spriteMode = SPRITE::PLAYER;
		}

		play.revertMorph();

		if(wasForcedFrog && play.charCurr != CHAR::FROG)
			SyncCharacterAfterForcedFrogRevert(play);
	}

	void QueueEvaRevert(int playerID)
	{
		if(playerID < 0 || playerID >= 32) return;
		if(!jjPlayers[playerID].isActive || !jjPlayers[playerID].isLocal) return;
		evaRevertPending[playerID] = true;
	}

	void ApplyFrogForceForCharacter(jjPLAYER@ play, int char, bool forceSend = false)
	{
		if(play is null || !play.isLocal || play.playerID < 0 || play.playerID >= 32) return;
		if(ShouldAutoForceFrog(char))
		{
			SetForceFrogVisual(play.playerID, true, false);
			// Large-level JJ2 bug: the morph state can already be FROG while JJ2
			// still renders a stale rabbit frame. Hide the native player sprite and
			// let DrawForcedFrog render the frog instead.
			setInvisibleSprite(play, true);
			UpdateForcedFrogLocalState(play, forceSend);
		}
		else if(!forceFrogManual[play.playerID])
		{
			SetForceFrogVisual(play.playerID, false, false);
		}
	}

	void ApplyFrogForceSettingToLocalPlayers()
	{
		for(int id = 0; id < 32; id++)
		{
			if(!jjPlayers[id].isActive) continue;
			if(characters[id] == CharAnim::FROG || jjPlayers[id].charCurr == CHAR::FROG)
			{
				if(jjPlayers[id].isLocal)
					ApplyFrogForceForCharacter(jjPlayers[id], CharAnim::FROG, true);
				else if(ShouldAutoForceFrog(CharAnim::FROG))
					SetForceFrogVisual(id, true, false);
				else if(!forceFrogManual[id])
					SetForceFrogVisual(id, false, false);
			}
			else if(!forceFrogManual[id])
				SetForceFrogVisual(id, false, false);
			if(jjIsServer) SendCharacterState(id);
		}
	}

	// Set by a separate visual-character module (for example CharsMonsters).
	// When true, this script must not draw the native/custom rabbit underneath it.
	array<bool> externalCharacterVisualSuppressed(32, false);

	void SetExternalCharacterVisualSuppressed(int playerID, bool suppress)
	{
		if(playerID < 0 || playerID >= int(externalCharacterVisualSuppressed.length)) return;
		externalCharacterVisualSuppressed[playerID] = suppress;
	}

	bool IsExternalCharacterVisualSuppressed(int playerID)
	{
		return playerID >= 0
			&& playerID < int(externalCharacterVisualSuppressed.length)
			&& externalCharacterVisualSuppressed[playerID];
	}

	float PlayerSizeScale(int playerID)
	{
		if(playerID < 0 || playerID >= int(charSizeMode.length)) return 1;
		if(charSizeMode[playerID] == CHAR_SIZE_TINY) return 0.5;
		if(charSizeMode[playerID] == CHAR_SIZE_GIANT) return 2;
		return 1;
	}

	int PlayerSizeModeFromString(const string &in value)
	{
		if(jjRegexMatch(value, "^tiny$", true)) return CHAR_SIZE_TINY;
		if(jjRegexMatch(value, "^normal$", true)) return CHAR_SIZE_NORMAL;
		if(jjRegexMatch(value, "^giant$", true)) return CHAR_SIZE_GIANT;
		return -1;
	}

	string PlayerSizeModeName(int mode)
	{
		if(mode == CHAR_SIZE_TINY) return "tiny";
		if(mode == CHAR_SIZE_GIANT) return "giant";
		return "normal";
	}

	void TransmitCharSize(jjPLAYER@ play)
	{
		IncrementLocalCharStateRevision(play.playerID);
		SendCharacterState(play.playerID);
		QueueLocalCharStateResends(play.playerID);
	}

	int TraceColorIndexFromValue(int value)
	{
		for(uint i = 0; i < TRACE_COLOR_VALUES.length; i++)
			if(TRACE_COLOR_VALUES[i] == value)
				return int(i);
		return -1;
	}

	string TraceColorLabel(int value)
	{
		int index = TraceColorIndexFromValue(value);
		if(index >= 0) return TRACE_COLOR_NAMES[index];
		return "" + value;
	}

	string TraceStyleLabel(int style)
	{
		if(style == TRACE_STYLE_INVERT) return "|ON";
		if(style == TRACE_STYLE_CONST) return "CONST";
		return "||OFF";
	}

	int TraceStyleLabelX()
	{
		return TRACE_STYLE == TRACE_STYLE_CONST ? 136 : 160;
	}

	void CycleTraceStyle()
	{
		TRACE_STYLE = (TRACE_STYLE + 1) % 3;
	}

	string TraceModeLabel(int mode)
	{
		if(mode == TRACE_MODE_RAINBOW) return "RAINBOW";
		if(mode == TRACE_MODE_RANDOM) return "RANDOM";
		return "COLOR";
	}

	void ApplyLocalTracePreference()
	{
		for(int i = 0; i < jjLocalPlayerCount; i++)
		{
			int id = jjLocalPlayers[i].playerID;
			if(id < 0 || id >= 32) continue;
			traceEnabledForPlayer[id] = TRACE_ENABLED;
			traceColorForPlayer[id] = TRACE_COLOR_VALUE;
			traceStyleForPlayer[id] = TRACE_STYLE;
			traceModeForPlayer[id] = TRACE_MODE;
			SendTraceState(id);
		}
	}

	void SendTraceState(int playerID, int toClientID = 0)
	{
		if(playerID < 0 || playerID >= 32) return;
		jjSTREAM packet;
		packet.push("!traceState " + playerID + " " + (traceEnabledForPlayer[playerID] ? 1 : 0) + " " + traceColorForPlayer[playerID] + " " + traceStyleForPlayer[playerID] + " " + traceModeForPlayer[playerID]);
		if(toClientID == 0) jjSendPacket(packet);
		else jjSendPacket(packet, toClientID);
	}

	void SendAllTraceStates(int toClientID = 0)
	{
		for(int i = 0; i < 32; i++)
			if(jjPlayers[i].isActive)
				SendTraceState(i, toClientID);
	}
	array<int> ff(32, 0);
	array<int> lf(32, 0); //Lori frame (for crouching)

	array<uint8> fur_c1(32,0);
	array<uint8> fur_c2(32,0);
	array<uint8> fur_c3(32,0);
	array<uint8> fur_c4(32,0);
	array<uint8> hangLedgeTicks(4, 0);
	array<bool> hangLedgeLeft(4, false);
	array<bool> hangLedgeRight(4, false);
	const int CHARACTER_STATE_RESYNC_INTERVAL = 700;
	int lastCharacterStateResyncTick = -CHARACTER_STATE_RESYNC_INTERVAL;

	// Per-network-player visual state. The local player owns ledge detection and
	// movement, then sends only the state transition to the server/other clients.
	array<bool> hangLedgeActive(32, false);
	array<int> hangLedgeFrame(32, 0);
	array<int> hangLedgeDirection(32, 1);
	array<uint> hangLedgeFrameTick(32, 0);

	///#region Transmit All Settings
	void TransmitSettings()
	{
		if(!transmitAll)
		{
			transmitAll = true;
			TransmitAll();
		}
		if(!transmitServer && !jjIsServer) //for clients
		{
			TransmitServer();
			transmitServer = true;
		}
	}

	bool BoolFromPacketValue(const string &in value)
	{
		return value == "true" || value == "1";
	}

	void EnsureLocalCharStateSession(int playerID)
	{
		if(playerID < 0 || playerID >= 32) return;
		if(charStateSession[playerID] == 0)
			charStateSession[playerID] = NewCharStateSession();
	}

	int NewCharStateSession()
	{
		if(charStateSessionCounter <= 0)
			charStateSessionCounter = int(jjRandom() % 1000000000) + 1;
		charStateSessionCounter++;
		if(charStateSessionCounter <= 0)
			charStateSessionCounter = 1;
		return charStateSessionCounter;
	}

	void ResetLocalCharStateSession(int playerID)
	{
		if(playerID < 0 || playerID >= 32) return;
		charStateSession[playerID] = NewCharStateSession();
		charStateRevision[playerID] = 0;
		receivedCharStateSession[playerID] = -1;
		receivedCharStateRevision[playerID] = -1;
		nativeMorphPendingUntil[playerID] = 0;
		nativeMorphExpectedChar[playerID] = -1;
		charStateResendUntil[playerID] = 0;
		charStateNextResendTick[playerID] = 0;
	}

	void IncrementLocalCharStateRevision(int playerID)
	{
		if(playerID < 0 || playerID >= 32) return;
		EnsureLocalCharStateSession(playerID);
		charStateRevision[playerID]++;
	}

	void QueueLocalCharStateResends(int playerID)
	{
		if(playerID < 0 || playerID >= 32) return;
		charStateResendUntil[playerID] = jjGameTicks + 56;
		charStateNextResendTick[playerID] = jjGameTicks + 7;
	}

	void ProcessLocalCharStateResends()
	{
		for(int i = 0; i < jjLocalPlayerCount; i++)
		{
			int playerID = jjLocalPlayers[i].playerID;
			if(playerID < 0 || playerID >= 32) continue;
			if(charStateResendUntil[playerID] <= jjGameTicks)
			{
				charStateResendUntil[playerID] = 0;
				continue;
			}
			if(jjGameTicks >= charStateNextResendTick[playerID])
			{
				SendCharacterState(playerID);
				charStateNextResendTick[playerID] = jjGameTicks + 14;
			}
		}
	}

	bool IsRevisionedCharStateAccepted(int playerID, int session, int revision)
	{
		if(playerID < 0 || playerID >= 32) return false;
		if(session == 0) return true;
		if(jjPlayers[playerID].isLocal && charStateSession[playerID] == session && revision < charStateRevision[playerID])
			return false;
		if(receivedCharStateSession[playerID] != session)
		{
			receivedCharStateSession[playerID] = session;
			receivedCharStateRevision[playerID] = -1;
		}
		if(revision < receivedCharStateRevision[playerID])
			return false;
		receivedCharStateRevision[playerID] = revision;
		return true;
	}

	bool IsNativeMorphChar(int char)
	{
		return char == CharAnim::JAZZ || char == CharAnim::SPAZ || char == CharAnim::LORI || char == CharAnim::LORI1;
	}

	int OutgoingCharStateSession(int playerID)
	{
		if(playerID < 0 || playerID >= 32) return 0;
		if(jjIsServer && !jjPlayers[playerID].isLocal && receivedCharStateSession[playerID] != -1)
			return receivedCharStateSession[playerID];
		EnsureLocalCharStateSession(playerID);
		return charStateSession[playerID];
	}

	int OutgoingCharStateRevision(int playerID)
	{
		if(playerID < 0 || playerID >= 32) return 0;
		if(jjIsServer && !jjPlayers[playerID].isLocal && receivedCharStateSession[playerID] != -1)
			return receivedCharStateRevision[playerID];
		return charStateRevision[playerID];
	}

	int NativeMorphExpectedCharacter(int char)
	{
		if(char == CharAnim::LORI1) return int(defaultChar);
		if(0 < char && char < int(chars.length)) return int(chars[char].DefaultCharacter);
		return int(defaultChar);
	}

	void MarkNativeMorphPending(int playerID, int char)
	{
		if(playerID < 0 || playerID >= 32) return;
		if(IsNativeMorphChar(char))
		{
			nativeMorphExpectedChar[playerID] = NativeMorphExpectedCharacter(char);
			nativeMorphPendingUntil[playerID] = jjGameTicks + 70;
		}
		else
		{
			nativeMorphExpectedChar[playerID] = -1;
			nativeMorphPendingUntil[playerID] = 0;
		}
	}

	bool IsNativeMorphPending(jjPLAYER@ play)
	{
		int playerID = play.playerID;
		if(playerID < 0 || playerID >= 32) return false;
		if(nativeMorphPendingUntil[playerID] <= jjGameTicks)
		{
			nativeMorphPendingUntil[playerID] = 0;
			nativeMorphExpectedChar[playerID] = -1;
			return false;
		}
		if(nativeMorphExpectedChar[playerID] == int(play.charCurr))
		{
			nativeMorphPendingUntil[playerID] = 0;
			nativeMorphExpectedChar[playerID] = -1;
			return false;
		}
		return true;
	}

	void SendCharacterState(int playerID, int toClientID = 0)
	{
		if(playerID < 0 || playerID >= 32) return;
		int session = OutgoingCharStateSession(playerID);
		int revision = OutgoingCharStateRevision(playerID);
		jjSTREAM myStream;
		myStream.push("!charState " + playerID + " " + session + " " + revision + " " + characters[playerID] + " " + lastCharacter[playerID] + " " + (doubleBlaster[playerID] ? 1 : 0) + " " + charSizeMode[playerID] + " " + (forceFrogVisual[playerID] ? 1 : 0) + " " + forceFrogAnimID[playerID] + " " + forceFrogFrameID[playerID] + " " + forceFrogDirection[playerID] + " " + (forceFrogManual[playerID] ? 1 : 0));
		if(toClientID == 0) jjSendPacket(myStream);
		else jjSendPacket(myStream, toClientID);
	}

	void SendAllCharacterStates(int toClientID = 0)
	{
		for(int i = 0; i < 32; i++)
			if(jjPlayers[i].isActive)
				SendCharacterState(i, toClientID);
	}

	void PeriodicCharacterStateResync()
	{
		if(!jjIsServer || jjGameTicks < lastCharacterStateResyncTick + CHARACTER_STATE_RESYNC_INTERVAL) return;
		lastCharacterStateResyncTick = jjGameTicks;
		SendAllCharacterStates();
	}

	void ClearCharacterVisualReasons(int playerID)
	{
		if(playerID < 0 || playerID >= 32) return;
		mixSharedVisibility::SetReason(playerID, mixSharedVisibility::REASON_CHAR_DEVAN, false);
		if(jjPlayers[playerID].isActive)
			mixSharedVisibility::Apply(jjPlayers[playerID]);
	}

	void getSettingsFromServer(string settingsString){
		array<string> lines = settingsString.split("\n");
		array<string> string_parts = lines[1].split(" ");
		for(uint i=0; i<chars.length; i++){
			chars[i].EnabledMorph = parseInt(string_parts[i]) == 1 ? true : false;
		}

		//custom moves
		string_parts = lines[2].split(" ");
		for(uint i=0; i<chars.length; i++){
			chars[i].CustomMoves = parseInt(string_parts[i]);
		}
		
		string_parts = lines[3].split(" ");
		pregameMorphOnly = string_parts[0] == "1" ? true : false;
		if(string_parts.length > 2)
			HANG_LEDGES_ENABLED = string_parts[2] == "1" ? true : false;
		if(string_parts.length > 3)
			FROG_FORCE_ENABLED = string_parts[3] == "1" ? true : false;
		if(string_parts.length > 4)
			TRACE_ADMIN_ENABLED = string_parts[4] == "1" ? true : false;
		if(string_parts.length > 5)
			CHARS_NEW_HIDING = string_parts[5] == "1" ? true : false;
		else if(lines.length > 4)
		{
			// Backward compatibility with the previous network format, where
			// hiding lived on its own fourth settings line.
			array<string> oldHideParts = lines[4].split(" ");
			if(oldHideParts.length > 0)
				CHARS_NEW_HIDING = oldHideParts[0] == "1" ? true : false;
		}
		if(string_parts.length > 6)
			MONSTER_MORPHS_ENABLED = string_parts[6] == "1" ? true : false;
		if(string_parts.length > 7)
			OBJECT_MORPHS_ENABLED = string_parts[7] == "1" ? true : false;
		if(string_parts.length > 8) FrogHunt::durationSeconds = parseInt(string_parts[8]);
		if(string_parts.length > 9) FrogHunt::witchCount = parseInt(string_parts[9]);
		if(string_parts.length > 10) FrogHunt::evaCount = parseInt(string_parts[10]);
		FrogHunt::ClampStoredConfig();
		if(!TRACE_ADMIN_ENABLED) ClearTraceFrames();
		ApplyFrogForceSettingToLocalPlayers();
		CharsMonsters::ApplyAdminMorphSettings(false);
		//no silent sound system setting here because that is only local
	}

	void sendSettingsToClient(){
		//enableMorph
		string line = "";
		for(uint i=0; i<chars.length; i++){
			line += (chars[i].EnabledMorph ? 1 : 0)+" ";
		}
		//custom moves
		line += "\n";
		for(uint i=0; i<chars.length; i++){
			line += chars[i].CustomMoves+" ";
		}
		line += "\n";
		line += (pregameMorphOnly ? "1" : "0") + " " + (silentSoundSystemActive ? "1" : "0") + " " + (HANG_LEDGES_ENABLED ? "1" : "0") + " " + (FROG_FORCE_ENABLED ? "1" : "0") + " " + (TRACE_ADMIN_ENABLED ? "1" : "0") + " " + (CHARS_NEW_HIDING ? "1" : "0") + " " + (MONSTER_MORPHS_ENABLED ? "1" : "0") + " " + (OBJECT_MORPHS_ENABLED ? "1" : "0") + " " + FrogHunt::durationSeconds + " " + FrogHunt::witchCount + " " + FrogHunt::evaCount;
		// Keep this old-format hiding line so older clients still understand it.
		line += "\n";
		line += CHARS_NEW_HIDING ? "1" : "0";
		jjSTREAM myStream;
		myStream.push("!settings \n"+line);
		jjSendPacket(myStream);
	}

	bool transmitAll = false;
	array<array<int>> originalFur(4, array<int>(4));
	void TransmitAll(){
		for(int i=0; i<jjLocalPlayerCount; i++){
			jjLocalPlayers[i].furGet(originalFur[i][0], originalFur[i][1], originalFur[i][2], originalFur[i][3]);
			int indexFur = jjLocalPlayers[i].playerID;
			if(originalFur[i][0] == 0 && originalFur[i][1] == 0 && originalFur[i][2] == 0 && originalFur[i][3] == 0){
				originalFur[i][0] = 16;
				originalFur[i][1] = 24;
				originalFur[i][2] = 32;
				originalFur[i][3] = 40;
			}
			fur_c1[indexFur] = originalFur[i][0];
			fur_c2[indexFur] = originalFur[i][1];
			fur_c3[indexFur] = originalFur[i][2];
			fur_c4[indexFur] = originalFur[i][3];
		}
		
		jjSTREAM load(Filename);
		if (!load.isEmpty()) {
			string line = "";
			while(!load.isEmpty()){
				load.getLine(line);
				array<string> string_parts = line.split(" ");
				if(parseInt(string_parts[1]) != 0){
					for(int i=0; i<jjLocalPlayerCount; i++){ //we analyze the lines, but it is still the min(lines==4, jjLocalPlayerCount) == jjLocalPlayerCount always
						if(compareStrings(string_parts[0], jjLocalPlayers[i].nameUnformatted, true)){
							int j = jjLocalPlayers[i].playerID;
							characters[j] = parseInt(string_parts[1]);
							if(characters[j] > 0){
								setInvisibleSprite(jjPlayers[j]);
								setCharacter(characters[j], true, jjPlayers[j].nameUnformatted);
							}else{
								setCharacter(characters[j], false, jjPlayers[j].nameUnformatted);
							}

							if(string_parts.length == 6){
								//load fur
								array<uint8> furColors(4);
								furColors[0] = parseInt(string_parts[2]);
								furColors[1] = parseInt(string_parts[3]);
								furColors[2] = parseInt(string_parts[4]);
								furColors[3] = parseInt(string_parts[5]);
								jjLocalPlayers[i].furSet(furColors[0], furColors[1], furColors[2], furColors[3]);
							}

							SendCharacterState(jjLocalPlayers[i].playerID);
							SendTraceState(jjLocalPlayers[i].playerID);
							//==jjLocalPlayers[i].spriteMode = SPRITE::INVISIBLE;
						}
					}
				}
			}
		}
		for(int i=0; i<jjLocalPlayerCount; i++)
			SendTraceState(jjLocalPlayers[i].playerID);
		if(jjIsServer) sendSettingsToClient();
	}

	bool transmitServer = false;
	void TransmitServer(){ //request transmission of all characters from the server
		jjSTREAM myStream;
		myStream.push("!all");
		jjSendPacket(myStream);
	}

	array<array<int>> savedFur(4, array<int>(4));
	void saveFile(bool saveFur = false){
		jjSTREAM load(Filename);
		jjSTREAM save;
		if (!load.isEmpty()) {
			string line = "";
			while(!load.isEmpty()){
				load.getLine(line);
				array<string> string_parts = line.split(" ");
				bool okWrite = true;
				for(int i=0; i<jjLocalPlayerCount; i++){ //we analyze the lines, but it is still the min(lines==4, jjLocalPlayerCount) == jjLocalPlayerCount always
					if(compareStrings(string_parts[0], jjLocalPlayers[i].nameUnformatted, true)){
						okWrite = false;
						if(!saveFur || (saveFur && savedFur[i][0] == -1)){
							if(string_parts.length == 6){
								savedFur[i][0] = parseInt(string_parts[2]);
								savedFur[i][1] = parseInt(string_parts[3]);
								savedFur[i][2] = parseInt(string_parts[4]);
								savedFur[i][3] = parseInt(string_parts[5]);
							}else{
								savedFur[i][0] = -1;
							}
						}
					}
				}
				if(okWrite){
					save.write(line+"\n");
				}
			}
		}

		for(int i=0; i<jjLocalPlayerCount; i++){
			int j = jjLocalPlayers[i].playerID;
			int c1,c2,c3,c4;
			jjLocalPlayers[i].furGet(c1,c2,c3,c4);
			if(saveFur && savedFur[i][0] == -2){
				save.write(jjLocalPlayers[i].nameUnformatted+" "+characters[j]+" "+c1+" "+c2+" "+c3+" "+c4+"\n");
			}else if(savedFur[i][0] > 0){
				save.write(jjLocalPlayers[i].nameUnformatted+" "+characters[j]+" "+savedFur[i][0]+" "+savedFur[i][1]+" "+savedFur[i][2]+" "+savedFur[i][3]+"\n");
			}else{
				save.write(jjLocalPlayers[i].nameUnformatted+" "+characters[j]+" "+c1+" "+c2+" "+c3+" "+c4+"\n");
			}
		}
		save.save(Filename);
	}
	///#endregion

	class DrawFrogVars
	{
		bool frogTransform = false;
		int ind2 = 17;
	}
	DrawFrogVars drawFrogFrames(int i, bool fromOnMain = false){
		jjPLAYER@ p = jjPlayers[i];
		int grav = p.antiGrav ? -1 : 1;
		SPRITE::Mode sprite_mode = p.frozen == 0 ? (p.shieldType == SHIELD::WATER ? SPRITE::TRANSLUCENTPLAYER : SPRITE::PLAYER) : SPRITE::FROZEN;
		int drawParam = i;
		if(isSugarRushing[i]){
			sprite_mode = sugarRushSprite();
			drawParam = sugarRushParam(p);
		}
		
		int lastChar = lastCharacter[i];
		int headOffsetX = 0; 
		if(isPlayerHead(lastChar) || isCustomHead(lastChar)){
			headOffsetX = getHeadOffsetX(p, lastChar);
		}
		
		int animationFrog = ANIM::FROG;
		int ind2 = PlayerRabbitAnimRelForSet(p, animationFrog); //FOR TSF
		bool condV = (!jjIsTSF && (ind2 == 3 || ind2 == 10)) || (jjIsTSF && (ind2 == 3 || ind2 == 11 || ind2 == 7 /*|| (jjIsServer && ind2 == -354)*/));
		if(CharAnim::MARIO <= lastChar && lastChar <= CharAnim::YOSHI){
			ind2 = 17; //Your custom character frog transform index (17 in Yoshi.j2a)
		}
		bool frogTransform = false;
		if(uint(lastChar) < chars.length)
		{
			bool hasFile = chars[lastChar].File == "";
			int animRel = PlayerRabbitAnimRel(p);
			DrawParams draw;
			if(0 < ind2 && ind2 < 90)
			{
				draw.add(
					xPos: p.xPos + (hasFile ? 0 : chars[lastChar].adjustX[ind2]*p.direction) - headOffsetX,
					yPos: p.yPos + (hasFile ? 0 : chars[lastChar].adjustY[ind2]*grav),
					animSet: ANIM::CUSTOM[chars[lastChar].Set],
					anim: (hasFile && chars[lastChar].animRel.length <= 1) ? animRel : chars[lastChar].animRel[ind2],
					frame: jjGameTicks/10,
					sizeX: hasFile ? 1*p.direction : 1*chars[lastChar].sizeX[ind2]*p.direction,
					sizeY: hasFile ? 1*grav : 1*chars[lastChar].sizeY[ind2]*grav,
					spriteMode: sprite_mode,
					param: p.playerID,
					grav: grav,
					playerID: p.playerID
				);
			}
			
			if((characters[i] == CharAnim::FROG || jjPlayers[i].charCurr == CHAR::FROG) && (lastChar > defaultCharCount) && condV){
				frogTransform = true;
				if(lastChar == CharAnim::EVA){
					draw.yPos = p.yPos-4*grav;
					draw.animSet = ANIM::EVA;
					draw.charactersMinSourceSet = -1;
					draw.anim = 0;
					draw.frame = jjGameTicks/20;
					draw.sizeX = -1*p.direction;
				}else if(lastChar == CharAnim::WITCH){
					draw.animSet = ANIM::WITCH;
					draw.charactersMinSourceSet = -1;
					draw.anim = p.keyFire ? 0 : 2;
				}else if(isPlayerHead(lastChar) || isCustomHead(lastChar)){
					draw.yPos = p.yPos+16*grav;
					draw.animSet = ANIM::FACES;
					draw.charactersMinSourceSet = -1;
					draw.anim = 2;
				}else if(lastChar == CharAnim::YOSHI){
					//this is playing the animation reversed, but you could have a custom animation for character -> frog
					if(jjGameTicks%8 == 0 && fromOnMain) ff[i]++;
					int frogFrame = (18 - ff[i]) > 0 ? (18 - ff[i]) : 0;
					if(ff[i] > 18) ff[i] = 0;
					if(frogFrame < 7){
						draw.xPos = p.xPos;// - 15*p.direction;
						draw.yPos = p.yPos;// - 20*grav;
						draw.sizeX = 1*p.direction;
						draw.sizeY = 1*grav;
					}
					draw.frame = frogFrame;
				}else if(lastChar == CharAnim::JJ1){
					int frogFrame = 18 - p.frameID;
					draw.frame = frogFrame;
				}else if(lastChar == CharAnim::TAILS){
					int frogFrame = 14 - p.frameID >= 0 ? 14 - p.frameID : 0; //14 = number of frames for Tails (could've done it with frogFrame = p.frameID * 14/18
					draw.frame = frogFrame;
				}else if(lastChar == CharAnim::LORI1 || lastChar == CharAnim::LORI){
					int frogFrame = 22 - p.frameID;
					draw.animSet = ANIM::CUSTOM[chars[CharAnim::LORI1].Set];
					SetCharactersMinSource(draw, CharAnim::LORI1);
					draw.anim = ind2;
					draw.frame = frogFrame;
				}else if(lastChar == CharAnim::DEVAN){
					int frogFrame = 18 - p.frameID;
					update(jjPlayers[i], i);
				}else if(lastChar == CharAnim::SONIC){
					int frogFrame = 18 - p.frameID;
					draw.anim = animRel;
					draw.frame = p.frameID;
				}else if(lastChar == CharAnim::MARIO){
					int frogFrame = 18 - p.frameID;
					draw.anim = animRel;
					draw.frame = p.frameID;
				}
				if(lastChar != CharAnim::DEVAN){
					DrawCharacter(draw);
				}
			}
		}
		DrawFrogVars drawFrogVars;
		drawFrogVars.frogTransform = frogTransform;
		drawFrogVars.ind2 = ind2;
		return drawFrogVars;
	}

	void checkSpectating(int i)
	{
		jjPLAYER@ p = jjPlayers[i];
		if(wasSpectating[p.localPlayerID] == false && p.isInGame == false && p.isActive && jjGameTicks >= 300 && p.isLocal)
		{
			lastRoast[p.localPlayerID] = true;
			//jjAlert("lastRoast1");
		}
		wasSpectating[p.localPlayerID] = !p.isInGame;
	}

	bool canUseHangLedgeAnimation(jjPLAYER@ player)
	{
		if(!HANG_LEDGES_ENABLED || !HANG_LEDGES_LOCAL_ENABLED || !player.isLocal || !player.isInGame)
			return false;
		if(player.localPlayerID < 0 || player.localPlayerID >= 4 || player.playerID < 0 || player.playerID >= 32)
			return false;

		// Monsters/objects own the sprite. Never start the rabbit ledge visual
		// underneath an external replacement sprite.
		if(IsExternalCharacterVisualSuppressed(player.playerID))
			return false;

		if(CharsNyanRush::ShouldSuppressVisuals(player.playerID))
			return false;

		return characters[player.playerID] == CharAnim::JAZZ || characters[player.playerID] == CharAnim::SPAZ;
	}

	void SetHangLedgeVisualState(int playerID, bool active, int direction = 1, int frame = 0)
	{
		if(playerID < 0 || playerID >= 32) return;

		hangLedgeActive[playerID] = active;
		hangLedgeDirection[playerID] = direction < 0 ? -1 : 1;
		hangLedgeFrame[playerID] = frame < 0 ? 0 : frame;
		hangLedgeFrameTick[playerID] = jjGameTicks;
	}

	void TransmitHangLedgeState(jjPLAYER@ player, bool active, int direction = 1, int frame = 0)
	{
		if(player is null || !player.isLocal) return;
		if(player.playerID < 0 || player.playerID >= 32) return;

		jjSTREAM packet;
		packet.push("!hangLedge " + player.playerID + " " + (active ? 1 : 0) + " " + (direction < 0 ? -1 : 1) + " " + (frame < 0 ? 0 : frame));
		jjSendPacket(packet);
	}

	void clearHangLedgeState(jjPLAYER@ player)
	{
		if(player is null) return;

		bool wasActive = false;
		if(player.playerID >= 0 && player.playerID < 32)
			wasActive = hangLedgeActive[player.playerID];

		if(player.localPlayerID >= 0 && player.localPlayerID < 4)
		{
			hangLedgeTicks[player.localPlayerID] = 0;
			hangLedgeLeft[player.localPlayerID] = false;
			hangLedgeRight[player.localPlayerID] = false;
		}

		if(player.playerID >= 0 && player.playerID < 32)
			SetHangLedgeVisualState(player.playerID, false);

		// Do not touch player.invisibility here. It is synchronized gameplay state;
		// hiding this custom animation belongs only in onPlayerDraw on each client.
		if(wasActive && player.isLocal)
			TransmitHangLedgeState(player, false);
	}

	void resetAllHangLedges()
	{
		for(int i = 0; i < 4; i++)
		{
			hangLedgeTicks[i] = 0;
			hangLedgeLeft[i] = false;
			hangLedgeRight[i] = false;
		}
		for(int i = 0; i < 32; i++)
			SetHangLedgeVisualState(i, false);
	}

	bool IsHangLedgeActive(int playerID)
	{
		return playerID >= 0 && playerID < 32 && hangLedgeActive[playerID];
	}

	int HangLedgeDrawAnim()
	{
		return jjIsTSF ? animIndex[HANG_LEDGE_SOURCE_ANIM] : HANG_LEDGE_SOURCE_ANIM;
	}

	int HangLedgeFrame(int playerID)
	{
		// Keep the original ledge frame timing exactly. The local player sends
		// the same frame it used to draw itself, once per ledge tick, so remote
		// clients do not need to extrapolate or clamp a different animation.
		if(playerID < 0 || playerID >= 32) return 0;
		return hangLedgeFrame[playerID];
	}

	int HangLedgeDirection(int playerID)
	{
		if(playerID < 0 || playerID >= 32) return 1;
		return hangLedgeDirection[playerID] < 0 ? -1 : 1;
	}

	void DrawHangLedgeSprite(jjPLAYER@ player, int animSet, int frame, int direction)
	{
		float scale = PlayerSizeScale(player.playerID);

		// Keep the existing normal-size render exactly unchanged.
		if(scale == 1)
		{
			RecordTraceFrame(player.playerID, player.xPos, player.yPos, animSet, HangLedgeDrawAnim(), frame, direction, 1);
			jjDrawSprite(
				int(player.xPos),
				int(player.yPos),
				animSet,
				HangLedgeDrawAnim(),
				frame,
				direction,
				SPRITE::PLAYER,
				player.playerID);
			return;
		}

		// Match the regular tiny/giant player renderer: scale around the feet,
		// using the same 23-pixel anchor that normal character drawing uses.
		float yOffset = (1 - scale) * 23;

		// Extra per-size visual correction for ledge hanging.
		if(scale < 1)
			yOffset += HANG_LEDGE_TINY_Y_OFFSET;
		else
			yOffset += HANG_LEDGE_GIANT_Y_OFFSET;

		jjDrawResizedSprite(
			player.xPos,
			player.yPos + yOffset,
			animSet,
			HangLedgeDrawAnim(),
			frame,
			direction * scale,
			scale,
			SPRITE::PLAYER,
			player.playerID);
		RecordTraceFrame(player.playerID, player.xPos, player.yPos + yOffset, animSet, HangLedgeDrawAnim(), frame, direction * scale, scale);
	}

	void DrawHangLedgePlayer(jjPLAYER@ player)
	{
		if(player is null || !IsHangLedgeActive(player.playerID)) return;
		if(IsExternalCharacterVisualSuppressed(player.playerID)) return;
		if(CharsNyanRush::ShouldSuppressVisuals(player.playerID)) return;
		if(characters[player.playerID] != CharAnim::JAZZ && characters[player.playerID] != CharAnim::SPAZ) return;

		int animSet = characters[player.playerID] == CharAnim::JAZZ ? ANIM::JAZZ : ANIM::SPAZ;
		DrawHangLedgeSprite(
			player,
			animSet,
			HangLedgeFrame(player.playerID),
			HangLedgeDirection(player.playerID));
	}

	void updateHangOnLedges(jjPLAYER@ player)
	{
		if(!canUseHangLedgeAnimation(player))
		{
			clearHangLedgeState(player);
			return;
		}

		int localID = player.localPlayerID;
		if(player.ySpeed > 0 && !hangLedgeLeft[localID] && !hangLedgeRight[localID])
		{
			if(!jjMaskedHLine(int(player.xPos) - 40, 40, int(player.yPos) - 13)
			&& !jjMaskedHLine(int(player.xPos) - 40, 40, int(player.yPos) - 29)
			&& jjMaskedVLine(int(player.xPos) - 13, int(player.yPos) - 13, 10)
			&& player.direction < 0)
			{
				hangLedgeTicks[localID] = 1;
				hangLedgeLeft[localID] = true;
			}
			if(!jjMaskedHLine(int(player.xPos), 40, int(player.yPos) - 13)
			&& !jjMaskedHLine(int(player.xPos), 40, int(player.yPos) - 29)
			&& jjMaskedVLine(int(player.xPos) + 13, int(player.yPos) - 13, 10)
			&& player.direction > 0)
			{
				hangLedgeTicks[localID] = 1;
				hangLedgeRight[localID] = true;
			}
		}

		if(hangLedgeTicks[localID] > 0)
		{
			hangLedgeTicks[localID] = hangLedgeTicks[localID] + 1;
			int direction = hangLedgeLeft[localID] ? -1 : 1;
			player.xPos = player.xPos + direction;
			player.yPos = player.yPos - 1;
			player.xSpeed = 0;
			player.ySpeed = 0;

			int frame = hangLedgeTicks[localID] / 3;
			SetHangLedgeVisualState(player.playerID, true, direction, frame);

			// Send the exact original frame every ledge tick. This is a brief
			// animation (at most 20 ticks), and it avoids a remote-only animation
			// timing/clamping path.
			TransmitHangLedgeState(player, true, direction, frame);

			// Preserve the original local selection, timing and normal-size path.
			// Tiny/giant now use the same scale and feet anchor as regular characters.
			DrawHangLedgeSprite(
				player,
				characters[player.playerID] == CharAnim::JAZZ ? ANIM::JAZZ : ANIM::SPAZ,
				frame,
				direction);
		}
		else
		{
			clearHangLedgeState(player);
			return;
		}

		if(hangLedgeTicks[localID] > 20 && !jjMaskedHLine(int(player.xPos) - 13, 26, int(player.yPos) + 21))
			clearHangLedgeState(player);
	}

	///#region Frog Hunt
	namespace FrogHunt
	{
		const int STATE_STOPPED = 0;
		const int STATE_RUNNING = 1;
		const int STATE_PAUSED = 2;

		const int ROLE_NONE = 0;
		const int ROLE_PLAYER = 1;
		const int ROLE_WITCH = 2;
		const int ROLE_EVA = 3;

		const int MIN_TIME_SECONDS = 60;
		const int MAX_TIME_SECONDS = 15 * 60;
		const int TIME_STEP_SECONDS = 15;
		const int RESTORE_DELAY_TICKS = 3 * 70;

		int state = STATE_STOPPED;
		int durationSeconds = 5 * 60;
		int remainingTicks = 5 * 60 * 70;
		int witchCount = 2;
		int evaCount = 1;
		array<int> roles(32, ROLE_NONE);
		int lastTimerTick = 0;
		int roundEndActiveTicks = 0;
		int winCheckAfterTick = 0;
		int lastBroadcastSecond = -1;
		bool nativeGameFrozenByFrogHunt = false;
		bool changedNoMovement = false;
		bool noMovementBeforeFreeze = false;
		bool customMovesOverridden = false;
		int previousWitchCustomMoves = 1;
		int previousEvaCustomMoves = 1;
		array<int> preRoundCharacter(32, -1);
		array<bool> preRoundCharacterSaved(32, false);
		bool restorePending = false;
		int restoreAtTick = 0;

		// Optional first-come, first-served role claims. These are intentionally
		// not saved to .asdat. Individual claims are cleared on level cycle and
		// after a completed/stopped round.
		bool assignmentsEnabled = true;
		array<int> requestedRole(32, ROLE_NONE);
		array<bool> previousRoundWitch(32, false);

		void ResetForLevel()
		{
			if(customMovesOverridden)
			{
				chars[CharAnim::WITCH].CustomMoves = previousWitchCustomMoves;
				chars[CharAnim::EVA].CustomMoves = previousEvaCustomMoves;
			}
			customMovesOverridden = false;
			state = STATE_STOPPED;
			remainingTicks = durationSeconds * 70;
			lastTimerTick = jjGameTicks;
			roundEndActiveTicks = 0;
			winCheckAfterTick = 0;
			lastBroadcastSecond = -1;
			restorePending = false;
			restoreAtTick = 0;
			nativeGameFrozenByFrogHunt = false;
			changedNoMovement = false;
			ClearRoles();
			for(int i = 0; i < 32; i++)
			{
				preRoundCharacter[i] = -1;
				preRoundCharacterSaved[i] = false;
				requestedRole[i] = ROLE_NONE;
				previousRoundWitch[i] = false;
			}
		}

		bool IsActiveRound() { return state == STATE_RUNNING || state == STATE_PAUSED; }

		string StateLabel()
		{
			if(state == STATE_RUNNING) return "RUNNING";
			if(state == STATE_PAUSED) return "PAUSED";
			return "STOPPED";
		}

		string FormatSeconds(int seconds)
		{
			if(seconds < 0) seconds = 0;
			int minutes = seconds / 60;
			int secs = seconds % 60;
			return minutes + ":" + (secs < 10 ? "0" : "") + secs;
		}

		string FormatRemainingTime() { return FormatSeconds((remainingTicks + 69) / 70); }

		void OnLocalPlayerTimerEnd(jjPLAYER@ player)
		{
			// The server owns Frog Hunt's win decision. The built-in Player Timer
			// is used here only as the synchronized visible HUD countdown.
		}

		void StartLocalPlayerTimers(int ticks, bool paused = false)
		{
			if(ticks < 1) ticks = 1;
			for(int i = 0; i < jjLocalPlayerCount; i++)
			{
				jjPLAYER@ player = jjLocalPlayers[i];
				if(!player.isActive || !player.isInGame) continue;
				player.timerPersists = true;
				player.timerFunction(OnLocalPlayerTimerEnd);
				player.timerStart(ticks, paused);
			}
		}

		void PauseLocalPlayerTimers()
		{
			for(int i = 0; i < jjLocalPlayerCount; i++)
			{
				jjPLAYER@ player = jjLocalPlayers[i];
				if(player.timerState == TIMER::STARTED) player.timerPause();
			}
		}

		void ResumeLocalPlayerTimers()
		{
			for(int i = 0; i < jjLocalPlayerCount; i++)
			{
				jjPLAYER@ player = jjLocalPlayers[i];
				if(player.timerState == TIMER::PAUSED) player.timerResume();
			}
		}

		void StopLocalPlayerTimers()
		{
			for(int i = 0; i < jjLocalPlayerCount; i++)
				if(jjLocalPlayers[i].timerState != TIMER::STOPPED)
					jjLocalPlayers[i].timerStop();
		}

		void SyncLocalPlayerTimers(int newState, int ticks)
		{
			if(ticks < 1) ticks = 1;
			for(int i = 0; i < jjLocalPlayerCount; i++)
			{
				jjPLAYER@ player = jjLocalPlayers[i];
				if(!player.isActive || !player.isInGame) continue;

				if(newState == STATE_STOPPED)
				{
					if(player.timerState != TIMER::STOPPED) player.timerStop();
					continue;
				}

				player.timerPersists = true;
				player.timerFunction(OnLocalPlayerTimerEnd);
				if(player.timerState == TIMER::STOPPED)
					player.timerStart(ticks, false);
				else
					player.timerTime = ticks;

				if(newState == STATE_PAUSED)
				{
					// Set the authoritative server value first, then pause explicitly.
					// Do not restart the timer in paused mode: repeated restarts caused
					// bogus timer values on some clients.
					player.timerTime = ticks;
					if(player.timerState == TIMER::STARTED) player.timerPause();
				}
				else if(newState == STATE_RUNNING)
				{
					player.timerTime = ticks;
					if(player.timerState == TIMER::PAUSED) player.timerResume();
				}
			}
		}

		void StartNativeGameTimer()
		{
			StartLocalPlayerTimers(durationSeconds * 70, false);
		}

		void ClearNativeGameTimer()
		{
			StopLocalPlayerTimers();
		}

		int CurrentRemainingTicks()
		{
			if(state == STATE_RUNNING || state == STATE_PAUSED)
			{
				int value = roundEndActiveTicks - int(jjActiveGameTicks);
				return value > 0 ? value : 0;
			}
			return state == STATE_STOPPED ? remainingTicks : 0;
		}

		array<int> ActivePlayerIDs()
		{
			array<int> ids;
			for(int i = 0; i < 32; i++)
				if(jjPlayers[i].isActive && jjPlayers[i].isInGame)
					ids.insertLast(i);
			return ids;
		}

		int ActivePlayerCount()
		{
			int count = 0;
			for(int i = 0; i < 32; i++)
				if(jjPlayers[i].isActive && jjPlayers[i].isInGame)
					count++;
			return count;
		}

		int MaxEvaCountForPlayers(int activePlayers)
		{
			int result = activePlayers - 2; // leave one Witch and one ordinary player
			if(result < 0) result = 0;
			if(result > 3) result = 3;
			return result;
		}

		int MaxWitchCountForPlayers(int activePlayers, int evas)
		{
			int result = activePlayers - 1 - evas; // leave at least one ordinary player
			if(result < 1) result = 1;
			return result;
		}

		void ClampStoredConfig()
		{
			if(durationSeconds < MIN_TIME_SECONDS) durationSeconds = MIN_TIME_SECONDS;
			if(durationSeconds > MAX_TIME_SECONDS) durationSeconds = MAX_TIME_SECONDS;
			if(witchCount < 1) witchCount = 1;
			if(witchCount > 31) witchCount = 31;
			if(evaCount < 0) evaCount = 0;
			if(evaCount > 3) evaCount = 3;
			if(state == STATE_STOPPED) remainingTicks = durationSeconds * 70;
		}

		void ClampConfigForActivePlayers(int activePlayers)
		{
			ClampStoredConfig();
			int maxEva = MaxEvaCountForPlayers(activePlayers);
			if(evaCount > maxEva) evaCount = maxEva;
			int maxWitches = MaxWitchCountForPlayers(activePlayers, evaCount);
			if(witchCount > maxWitches) witchCount = maxWitches;
			if(witchCount < 1) witchCount = 1;
		}

		bool IsAdminClient(int clientID)
		{
			if(clientID == 0) return true;
			for(int i = 0; i < 32; i++)
				if(jjPlayers[i].isActive && jjPlayers[i].clientID == clientID && jjPlayers[i].hasPrivilege("admin"))
					return true;
			return false;
		}

		void ClearRoles() { for(int i = 0; i < 32; i++) roles[i] = ROLE_NONE; }

		int AssignedCount(int role)
		{
			int count = 0;
			for(int i = 0; i < 32; i++)
				if(requestedRole[i] == role) count++;
			return count;
		}

		int AssignmentSpotsLeft(int role)
		{
			int maximum = role == ROLE_WITCH ? witchCount : role == ROLE_EVA ? evaCount : 0;
			int left = maximum - AssignedCount(role);
			return left > 0 ? left : 0;
		}

		void ClearSelfAssignments()
		{
			for(int i = 0; i < 32; i++) requestedRole[i] = ROLE_NONE;
		}

		void SendAssignmentState(int toClientID = 0)
		{
			if(!jjIsServer) return;
			string message = "!frogHuntAssignmentState " + (assignmentsEnabled ? "1" : "0");
			for(int i = 0; i < 32; i++) message += " " + requestedRole[i];
			jjSTREAM packet;
			packet.push(message);
			if(toClientID == 0) jjSendPacket(packet);
			else jjSendPacket(packet, toClientID);
		}

		void ReceiveAssignmentState(const array<string> &in parts)
		{
			if(parts.length < 2) return;
			assignmentsEnabled = parseInt(parts[1]) != 0;
			for(int i = 0; i < 32; i++)
			{
				int partIndex = i + 2;
				requestedRole[i] = partIndex < int(parts.length) ? parseInt(parts[partIndex]) : ROLE_NONE;
				if(requestedRole[i] != ROLE_WITCH && requestedRole[i] != ROLE_EVA) requestedRole[i] = ROLE_NONE;
			}
		}

		bool PruneInvalidAssignments()
		{
			bool changed = false;
			for(int i = 0; i < 32; i++)
			{
				if(requestedRole[i] == ROLE_NONE) continue;
				if(!jjPlayers[i].isActive || !jjPlayers[i].isInGame)
				{
					requestedRole[i] = ROLE_NONE;
					changed = true;
				}
			}
			return changed;
		}

		void ToggleAssignments(int requestClientID = 0)
		{
			if(!jjIsServer || state != STATE_STOPPED || restorePending) return;
			assignmentsEnabled = !assignmentsEnabled;
			if(!assignmentsEnabled) ClearSelfAssignments();
			SendAssignmentState();
			NotifyRequester("Frog Hunt assignments: " + (assignmentsEnabled ? "ON" : "OFF"), requestClientID);
		}

		void SetSelfAssignment(int playerID, int role, int requestClientID = 0)
		{
			if(!jjIsServer) return;
			if(!assignmentsEnabled)
			{
				NotifyRequester("Frog Hunt assignments are disabled by the server.", requestClientID);
				return;
			}
			if(state != STATE_STOPPED || restorePending)
			{
				NotifyRequester("Frog Hunt assignments are only available while the gamemode is stopped.", requestClientID);
				return;
			}
			if(playerID < 0 || playerID >= 32 || !jjPlayers[playerID].isActive || !jjPlayers[playerID].isInGame)
			{
				NotifyRequester("You must be an active player to choose a Frog Hunt role.", requestClientID);
				return;
			}
			if(role != ROLE_WITCH && role != ROLE_EVA) return;
			if(PruneInvalidAssignments()) SendAssignmentState();
			if(requestedRole[playerID] == role)
			{
				NotifyRequester("You are already assigned as " + RoleLabel(role) + ".", requestClientID);
				return;
			}

			int occupied = AssignedCount(role);
			int maximum = role == ROLE_WITCH ? witchCount : evaCount;
			if(occupied >= maximum)
			{
				NotifyRequester(role == ROLE_WITCH ? "I am sorry, all witch spots are occupied" : "I am sorry, all Eva spots are occupied", requestClientID);
				return;
			}

			requestedRole[playerID] = role;
			SendAssignmentState();
			NotifyRequester("Frog Hunt: you are assigned as " + RoleLabel(role) + ".", requestClientID);
		}

		void RequestSelfAssignment(int role, int localPlayerIndex = 0)
		{
			if(jjLocalPlayerCount <= 0) return;
			if(localPlayerIndex < 0 || localPlayerIndex >= jjLocalPlayerCount)
			{
				jjAlert("Invalid local player number. Use 1-" + jjLocalPlayerCount + ".", false);
				return;
			}
			int playerID = jjLocalPlayers[localPlayerIndex].playerID;
			if(jjIsServer)
			{
				SetSelfAssignment(playerID, role, 0);
				return;
			}
			jjSTREAM packet;
			packet.push("!frogHuntMeRequest " + playerID + " " + role);
			jjSendPacket(packet);
		}

		void RememberCurrentWitchesForNextRound()
		{
			for(int i = 0; i < 32; i++) previousRoundWitch[i] = roles[i] == ROLE_WITCH;
		}

		array<int> RandomWitchOrder(const array<int> &in ids)
		{
			array<int> fresh;
			array<int> repeats;
			for(uint i = 0; i < ids.length; i++)
			{
				int id = ids[i];
				if(previousRoundWitch[id]) repeats.insertLast(id);
				else fresh.insertLast(id);
			}
			fresh = Shuffle(fresh);
			repeats = Shuffle(repeats);
			for(uint i = 0; i < repeats.length; i++) fresh.insertLast(repeats[i]);
			return fresh;
		}

		void ClearPreRoundCharacterMemory()
		{
			for(int i = 0; i < 32; i++)
			{
				preRoundCharacter[i] = -1;
				preRoundCharacterSaved[i] = false;
			}
		}

		int CharacterIndexForPreRoundSave(int playerID)
		{
			if(playerID < 0 || playerID >= 32) return CharAnim::SPAZ;
			jjPLAYER@ player = jjPlayers[playerID];
			int charIndex = characters[playerID];
			// Native Frog can exist even if the custom-character slot has not yet
			// caught up, so prioritize that visible/native morph for exact restore.
			if(player.charCurr == CHAR::FROG) return CharAnim::FROG;
			if(charIndex > 0 && charIndex < int(chars.length)) return charIndex;
			if(player.charCurr == CHAR::JAZZ) return CharAnim::JAZZ;
			if(player.charCurr == CHAR::LORI) return CharAnim::LORI;
			if(player.charCurr == CHAR::BIRD2) return CharAnim::BIRD2;
			if(player.charCurr == CHAR::BIRD) return CharAnim::BIRD;
			return CharAnim::SPAZ;
		}

		bool IsFrogHuntRoleCharacter(int charIndex)
		{
			return charIndex == CharAnim::WITCH
				|| charIndex == CharAnim::EVA
				|| charIndex == CharAnim::FROG;
		}

		void RememberPreRoundCharacterLocally(int playerID)
		{
			if(playerID < 0 || playerID >= 32 || preRoundCharacterSaved[playerID]) return;
			for(int localIndex = 0; localIndex < jjLocalPlayerCount; localIndex++)
			{
				if(jjLocalPlayers[localIndex].playerID != playerID) continue;
				// Preserve the exact pre-round morph, including Witch, Eva or Frog.
				// Those three are normalized only temporarily if this participant is
				// assigned the ordinary Player role; they are still restored afterward.
				preRoundCharacter[playerID] = CharacterIndexForPreRoundSave(playerID);
				preRoundCharacterSaved[playerID] = true;
				return;
			}
		}

		void RestorePreRoundCharacterLocally(int playerID)
		{
			if(playerID < 0 || playerID >= 32 || !preRoundCharacterSaved[playerID]) return;
			for(int localIndex = 0; localIndex < jjLocalPlayerCount; localIndex++)
			{
				if(jjLocalPlayers[localIndex].playerID != playerID) continue;
				int charIndex = preRoundCharacter[playerID];
				if(charIndex > 0 && charIndex < int(chars.length))
					setCharacter(charIndex, true, "" + (localIndex + 1), false, false);
				preRoundCharacter[playerID] = -1;
				preRoundCharacterSaved[playerID] = false;
				return;
			}
		}

		void RestoreAllParticipants()
		{
			for(int playerID = 0; playerID < 32; playerID++)
			{
				if(roles[playerID] == ROLE_NONE) continue;
				if(jjPlayers[playerID].isLocal)
					RestorePreRoundCharacterLocally(playerID);
				else if(jjIsServer && jjPlayers[playerID].isActive)
				{
					jjSTREAM packet;
					packet.push("!frogHuntRestore " + playerID);
					jjSendPacket(packet, jjPlayers[playerID].clientID);
				}
			}
		}

		void CompletePendingRestore()
		{
			if(!restorePending) return;
			RestoreAllParticipants();
			ClearRoles();
			restorePending = false;
			restoreAtTick = 0;
		}


		int BunnyCharForPlayer(int playerID)
		{
			if(playerID < 0 || playerID >= 32) return CharAnim::SPAZ;
			if(jjPlayers[playerID].charOrig == CHAR::JAZZ) return CharAnim::JAZZ;

[preview ends here]