diff --git a/public/animations/CNs001Bd.ani b/public/animations/CNs001Bd.ani
new file mode 100644
index 0000000..b1a5d31
Binary files /dev/null and b/public/animations/CNs001Bd.ani differ
diff --git a/public/animations/CNs001Br.ani b/public/animations/CNs001Br.ani
new file mode 100644
index 0000000..0daf183
Binary files /dev/null and b/public/animations/CNs001Br.ani differ
diff --git a/public/animations/CNs001La.ani b/public/animations/CNs001La.ani
new file mode 100644
index 0000000..c2ba4bf
Binary files /dev/null and b/public/animations/CNs001La.ani differ
diff --git a/public/animations/CNs001Ma.ani b/public/animations/CNs001Ma.ani
new file mode 100644
index 0000000..410cc63
Binary files /dev/null and b/public/animations/CNs001Ma.ani differ
diff --git a/public/animations/CNs001Ni.ani b/public/animations/CNs001Ni.ani
new file mode 100644
index 0000000..8f4ba36
Binary files /dev/null and b/public/animations/CNs001Ni.ani differ
diff --git a/public/animations/CNs001Pa.ani b/public/animations/CNs001Pa.ani
new file mode 100644
index 0000000..f8d7aea
Binary files /dev/null and b/public/animations/CNs001Pa.ani differ
diff --git a/public/animations/CNs001Pe.ani b/public/animations/CNs001Pe.ani
new file mode 100644
index 0000000..d3f902a
Binary files /dev/null and b/public/animations/CNs001Pe.ani differ
diff --git a/public/animations/CNs001Pg.ani b/public/animations/CNs001Pg.ani
new file mode 100644
index 0000000..b50aeae
Binary files /dev/null and b/public/animations/CNs001Pg.ani differ
diff --git a/public/animations/CNs001Rd.ani b/public/animations/CNs001Rd.ani
new file mode 100644
index 0000000..6957838
Binary files /dev/null and b/public/animations/CNs001Rd.ani differ
diff --git a/public/animations/CNs001Sk.ani b/public/animations/CNs001Sk.ani
new file mode 100644
index 0000000..335823e
Binary files /dev/null and b/public/animations/CNs001Sk.ani differ
diff --git a/public/animations/CNs001Sy.ani b/public/animations/CNs001Sy.ani
new file mode 100644
index 0000000..209803b
Binary files /dev/null and b/public/animations/CNs001Sy.ani differ
diff --git a/public/animations/CNs001xx.ani b/public/animations/CNs001xx.ani
new file mode 100644
index 0000000..62fbff1
Binary files /dev/null and b/public/animations/CNs001xx.ani differ
diff --git a/public/animations/CNs002Br.ani b/public/animations/CNs002Br.ani
new file mode 100644
index 0000000..6803749
Binary files /dev/null and b/public/animations/CNs002Br.ani differ
diff --git a/public/animations/CNs002La.ani b/public/animations/CNs002La.ani
new file mode 100644
index 0000000..ee3e7eb
Binary files /dev/null and b/public/animations/CNs002La.ani differ
diff --git a/public/animations/CNs002Ma.ani b/public/animations/CNs002Ma.ani
new file mode 100644
index 0000000..8bf4b55
Binary files /dev/null and b/public/animations/CNs002Ma.ani differ
diff --git a/public/animations/CNs002Ni.ani b/public/animations/CNs002Ni.ani
new file mode 100644
index 0000000..00f0462
Binary files /dev/null and b/public/animations/CNs002Ni.ani differ
diff --git a/public/animations/CNs002Pa.ani b/public/animations/CNs002Pa.ani
new file mode 100644
index 0000000..9e3da0a
Binary files /dev/null and b/public/animations/CNs002Pa.ani differ
diff --git a/public/animations/CNs002Pe.ani b/public/animations/CNs002Pe.ani
new file mode 100644
index 0000000..e952e1f
Binary files /dev/null and b/public/animations/CNs002Pe.ani differ
diff --git a/public/animations/CNs002xx.ani b/public/animations/CNs002xx.ani
new file mode 100644
index 0000000..8f2082a
Binary files /dev/null and b/public/animations/CNs002xx.ani differ
diff --git a/public/animations/CNs003Br.ani b/public/animations/CNs003Br.ani
new file mode 100644
index 0000000..35bb8b0
Binary files /dev/null and b/public/animations/CNs003Br.ani differ
diff --git a/public/animations/CNs003La.ani b/public/animations/CNs003La.ani
new file mode 100644
index 0000000..0df3497
Binary files /dev/null and b/public/animations/CNs003La.ani differ
diff --git a/public/animations/CNs003Ma.ani b/public/animations/CNs003Ma.ani
new file mode 100644
index 0000000..e25df2b
Binary files /dev/null and b/public/animations/CNs003Ma.ani differ
diff --git a/public/animations/CNs003Ni.ani b/public/animations/CNs003Ni.ani
new file mode 100644
index 0000000..cde9468
Binary files /dev/null and b/public/animations/CNs003Ni.ani differ
diff --git a/public/animations/CNs003Pa.ani b/public/animations/CNs003Pa.ani
new file mode 100644
index 0000000..52d2a4f
Binary files /dev/null and b/public/animations/CNs003Pa.ani differ
diff --git a/public/animations/CNs003Pe.ani b/public/animations/CNs003Pe.ani
new file mode 100644
index 0000000..3cbd327
Binary files /dev/null and b/public/animations/CNs003Pe.ani differ
diff --git a/public/animations/CNs003xx.ani b/public/animations/CNs003xx.ani
new file mode 100644
index 0000000..e59cf0d
Binary files /dev/null and b/public/animations/CNs003xx.ani differ
diff --git a/public/animations/CNs004Br.ani b/public/animations/CNs004Br.ani
new file mode 100644
index 0000000..79802ca
Binary files /dev/null and b/public/animations/CNs004Br.ani differ
diff --git a/public/animations/CNs004La.ani b/public/animations/CNs004La.ani
new file mode 100644
index 0000000..d6e701a
Binary files /dev/null and b/public/animations/CNs004La.ani differ
diff --git a/public/animations/CNs004Ma.ani b/public/animations/CNs004Ma.ani
new file mode 100644
index 0000000..c8563eb
Binary files /dev/null and b/public/animations/CNs004Ma.ani differ
diff --git a/public/animations/CNs004Ni.ani b/public/animations/CNs004Ni.ani
new file mode 100644
index 0000000..4ba7684
Binary files /dev/null and b/public/animations/CNs004Ni.ani differ
diff --git a/public/animations/CNs004Pa.ani b/public/animations/CNs004Pa.ani
new file mode 100644
index 0000000..1f16fca
Binary files /dev/null and b/public/animations/CNs004Pa.ani differ
diff --git a/public/animations/CNs004Pe.ani b/public/animations/CNs004Pe.ani
new file mode 100644
index 0000000..1254b4c
Binary files /dev/null and b/public/animations/CNs004Pe.ani differ
diff --git a/public/animations/CNs004xx.ani b/public/animations/CNs004xx.ani
new file mode 100644
index 0000000..1939a7c
Binary files /dev/null and b/public/animations/CNs004xx.ani differ
diff --git a/public/animations/CNs005Br.ani b/public/animations/CNs005Br.ani
new file mode 100644
index 0000000..f9967d4
Binary files /dev/null and b/public/animations/CNs005Br.ani differ
diff --git a/public/animations/CNs005La.ani b/public/animations/CNs005La.ani
new file mode 100644
index 0000000..ee1fd0d
Binary files /dev/null and b/public/animations/CNs005La.ani differ
diff --git a/public/animations/CNs005Ma.ani b/public/animations/CNs005Ma.ani
new file mode 100644
index 0000000..048487c
Binary files /dev/null and b/public/animations/CNs005Ma.ani differ
diff --git a/public/animations/CNs005Ni.ani b/public/animations/CNs005Ni.ani
new file mode 100644
index 0000000..18efc89
Binary files /dev/null and b/public/animations/CNs005Ni.ani differ
diff --git a/public/animations/CNs005Pa.ani b/public/animations/CNs005Pa.ani
new file mode 100644
index 0000000..00dfdde
Binary files /dev/null and b/public/animations/CNs005Pa.ani differ
diff --git a/public/animations/CNs005Pe.ani b/public/animations/CNs005Pe.ani
new file mode 100644
index 0000000..4d083ca
Binary files /dev/null and b/public/animations/CNs005Pe.ani differ
diff --git a/public/animations/CNs005xx.ani b/public/animations/CNs005xx.ani
new file mode 100644
index 0000000..ab4f62a
Binary files /dev/null and b/public/animations/CNs005xx.ani differ
diff --git a/public/animations/CNs006Br.ani b/public/animations/CNs006Br.ani
new file mode 100644
index 0000000..4a8d473
Binary files /dev/null and b/public/animations/CNs006Br.ani differ
diff --git a/public/animations/CNs006La.ani b/public/animations/CNs006La.ani
new file mode 100644
index 0000000..d26ccb4
Binary files /dev/null and b/public/animations/CNs006La.ani differ
diff --git a/public/animations/CNs006Ma.ani b/public/animations/CNs006Ma.ani
new file mode 100644
index 0000000..19d6c63
Binary files /dev/null and b/public/animations/CNs006Ma.ani differ
diff --git a/public/animations/CNs006Ni.ani b/public/animations/CNs006Ni.ani
new file mode 100644
index 0000000..19caa5e
Binary files /dev/null and b/public/animations/CNs006Ni.ani differ
diff --git a/public/animations/CNs006Pa.ani b/public/animations/CNs006Pa.ani
new file mode 100644
index 0000000..ce6fc4f
Binary files /dev/null and b/public/animations/CNs006Pa.ani differ
diff --git a/public/animations/CNs006Pe.ani b/public/animations/CNs006Pe.ani
new file mode 100644
index 0000000..22cfbf4
Binary files /dev/null and b/public/animations/CNs006Pe.ani differ
diff --git a/public/animations/CNs006xx.ani b/public/animations/CNs006xx.ani
new file mode 100644
index 0000000..2f184c3
Binary files /dev/null and b/public/animations/CNs006xx.ani differ
diff --git a/public/animations/CNs007Br.ani b/public/animations/CNs007Br.ani
new file mode 100644
index 0000000..e922568
Binary files /dev/null and b/public/animations/CNs007Br.ani differ
diff --git a/public/animations/CNs007La.ani b/public/animations/CNs007La.ani
new file mode 100644
index 0000000..45aa6fe
Binary files /dev/null and b/public/animations/CNs007La.ani differ
diff --git a/public/animations/CNs007Ma.ani b/public/animations/CNs007Ma.ani
new file mode 100644
index 0000000..cf32611
Binary files /dev/null and b/public/animations/CNs007Ma.ani differ
diff --git a/public/animations/CNs007Ni.ani b/public/animations/CNs007Ni.ani
new file mode 100644
index 0000000..53bf5ad
Binary files /dev/null and b/public/animations/CNs007Ni.ani differ
diff --git a/public/animations/CNs007Pa.ani b/public/animations/CNs007Pa.ani
new file mode 100644
index 0000000..2321a87
Binary files /dev/null and b/public/animations/CNs007Pa.ani differ
diff --git a/public/animations/CNs007Pe.ani b/public/animations/CNs007Pe.ani
new file mode 100644
index 0000000..7437aa7
Binary files /dev/null and b/public/animations/CNs007Pe.ani differ
diff --git a/public/animations/CNs007xx.ani b/public/animations/CNs007xx.ani
new file mode 100644
index 0000000..e300e24
Binary files /dev/null and b/public/animations/CNs007xx.ani differ
diff --git a/public/animations/CNs008Br.ani b/public/animations/CNs008Br.ani
new file mode 100644
index 0000000..f076b97
Binary files /dev/null and b/public/animations/CNs008Br.ani differ
diff --git a/public/animations/CNs008La.ani b/public/animations/CNs008La.ani
new file mode 100644
index 0000000..63ab4d9
Binary files /dev/null and b/public/animations/CNs008La.ani differ
diff --git a/public/animations/CNs008Ma.ani b/public/animations/CNs008Ma.ani
new file mode 100644
index 0000000..916b7a8
Binary files /dev/null and b/public/animations/CNs008Ma.ani differ
diff --git a/public/animations/CNs008Ni.ani b/public/animations/CNs008Ni.ani
new file mode 100644
index 0000000..2f59fe3
Binary files /dev/null and b/public/animations/CNs008Ni.ani differ
diff --git a/public/animations/CNs008Pa.ani b/public/animations/CNs008Pa.ani
new file mode 100644
index 0000000..3dfe5b5
Binary files /dev/null and b/public/animations/CNs008Pa.ani differ
diff --git a/public/animations/CNs008Pe.ani b/public/animations/CNs008Pe.ani
new file mode 100644
index 0000000..4539055
Binary files /dev/null and b/public/animations/CNs008Pe.ani differ
diff --git a/public/animations/CNs008xx.ani b/public/animations/CNs008xx.ani
new file mode 100644
index 0000000..18499ab
Binary files /dev/null and b/public/animations/CNs008xx.ani differ
diff --git a/public/animations/CNs009Br.ani b/public/animations/CNs009Br.ani
new file mode 100644
index 0000000..6268e5a
Binary files /dev/null and b/public/animations/CNs009Br.ani differ
diff --git a/public/animations/CNs009La.ani b/public/animations/CNs009La.ani
new file mode 100644
index 0000000..42d188e
Binary files /dev/null and b/public/animations/CNs009La.ani differ
diff --git a/public/animations/CNs009Ma.ani b/public/animations/CNs009Ma.ani
new file mode 100644
index 0000000..8d6447a
Binary files /dev/null and b/public/animations/CNs009Ma.ani differ
diff --git a/public/animations/CNs009Ni.ani b/public/animations/CNs009Ni.ani
new file mode 100644
index 0000000..8f5c7bb
Binary files /dev/null and b/public/animations/CNs009Ni.ani differ
diff --git a/public/animations/CNs009Pa.ani b/public/animations/CNs009Pa.ani
new file mode 100644
index 0000000..d312ca9
Binary files /dev/null and b/public/animations/CNs009Pa.ani differ
diff --git a/public/animations/CNs009Pe.ani b/public/animations/CNs009Pe.ani
new file mode 100644
index 0000000..47b6861
Binary files /dev/null and b/public/animations/CNs009Pe.ani differ
diff --git a/public/animations/CNs009xx.ani b/public/animations/CNs009xx.ani
new file mode 100644
index 0000000..9284109
Binary files /dev/null and b/public/animations/CNs009xx.ani differ
diff --git a/public/animations/CNs010Br.ani b/public/animations/CNs010Br.ani
new file mode 100644
index 0000000..4381c88
Binary files /dev/null and b/public/animations/CNs010Br.ani differ
diff --git a/public/animations/CNs010La.ani b/public/animations/CNs010La.ani
new file mode 100644
index 0000000..820d174
Binary files /dev/null and b/public/animations/CNs010La.ani differ
diff --git a/public/animations/CNs010Ma.ani b/public/animations/CNs010Ma.ani
new file mode 100644
index 0000000..55d6268
Binary files /dev/null and b/public/animations/CNs010Ma.ani differ
diff --git a/public/animations/CNs010Ni.ani b/public/animations/CNs010Ni.ani
new file mode 100644
index 0000000..c588f98
Binary files /dev/null and b/public/animations/CNs010Ni.ani differ
diff --git a/public/animations/CNs010Pa.ani b/public/animations/CNs010Pa.ani
new file mode 100644
index 0000000..ebd143e
Binary files /dev/null and b/public/animations/CNs010Pa.ani differ
diff --git a/public/animations/CNs010Pe.ani b/public/animations/CNs010Pe.ani
new file mode 100644
index 0000000..0d90583
Binary files /dev/null and b/public/animations/CNs010Pe.ani differ
diff --git a/public/animations/CNs010xx.ani b/public/animations/CNs010xx.ani
new file mode 100644
index 0000000..71c095a
Binary files /dev/null and b/public/animations/CNs010xx.ani differ
diff --git a/public/animations/CNs011Br.ani b/public/animations/CNs011Br.ani
new file mode 100644
index 0000000..088ce7a
Binary files /dev/null and b/public/animations/CNs011Br.ani differ
diff --git a/public/animations/CNs011La.ani b/public/animations/CNs011La.ani
new file mode 100644
index 0000000..acc759e
Binary files /dev/null and b/public/animations/CNs011La.ani differ
diff --git a/public/animations/CNs011Ma.ani b/public/animations/CNs011Ma.ani
new file mode 100644
index 0000000..f24074e
Binary files /dev/null and b/public/animations/CNs011Ma.ani differ
diff --git a/public/animations/CNs011Ni.ani b/public/animations/CNs011Ni.ani
new file mode 100644
index 0000000..0e16e10
Binary files /dev/null and b/public/animations/CNs011Ni.ani differ
diff --git a/public/animations/CNs011Pa.ani b/public/animations/CNs011Pa.ani
new file mode 100644
index 0000000..43ab4d3
Binary files /dev/null and b/public/animations/CNs011Pa.ani differ
diff --git a/public/animations/CNs011xx.ani b/public/animations/CNs011xx.ani
new file mode 100644
index 0000000..692adeb
Binary files /dev/null and b/public/animations/CNs011xx.ani differ
diff --git a/public/animations/CNs012Br.ani b/public/animations/CNs012Br.ani
new file mode 100644
index 0000000..d0e4757
Binary files /dev/null and b/public/animations/CNs012Br.ani differ
diff --git a/public/animations/CNs012Ma.ani b/public/animations/CNs012Ma.ani
new file mode 100644
index 0000000..51e4466
Binary files /dev/null and b/public/animations/CNs012Ma.ani differ
diff --git a/public/animations/CNs012Pa.ani b/public/animations/CNs012Pa.ani
new file mode 100644
index 0000000..b80a9c3
Binary files /dev/null and b/public/animations/CNs012Pa.ani differ
diff --git a/public/animations/CNs012xx.ani b/public/animations/CNs012xx.ani
new file mode 100644
index 0000000..14abd27
Binary files /dev/null and b/public/animations/CNs012xx.ani differ
diff --git a/public/animations/CNs013Br.ani b/public/animations/CNs013Br.ani
new file mode 100644
index 0000000..027e4f8
Binary files /dev/null and b/public/animations/CNs013Br.ani differ
diff --git a/public/animations/CNs013Ma.ani b/public/animations/CNs013Ma.ani
new file mode 100644
index 0000000..9e0779a
Binary files /dev/null and b/public/animations/CNs013Ma.ani differ
diff --git a/public/animations/CNs013Pa.ani b/public/animations/CNs013Pa.ani
new file mode 100644
index 0000000..c3a187f
Binary files /dev/null and b/public/animations/CNs013Pa.ani differ
diff --git a/public/animations/CNs014Br.ani b/public/animations/CNs014Br.ani
new file mode 100644
index 0000000..ed7a0bd
Binary files /dev/null and b/public/animations/CNs014Br.ani differ
diff --git a/public/animations/CNs0x4Ma.ani b/public/animations/CNs0x4Ma.ani
new file mode 100644
index 0000000..27ec720
Binary files /dev/null and b/public/animations/CNs0x4Ma.ani differ
diff --git a/public/animations/CNs0x4Pa.ani b/public/animations/CNs0x4Pa.ani
new file mode 100644
index 0000000..3ad2f7f
Binary files /dev/null and b/public/animations/CNs0x4Pa.ani differ
diff --git a/public/animations/CNs900Br.ani b/public/animations/CNs900Br.ani
new file mode 100644
index 0000000..5214c41
Binary files /dev/null and b/public/animations/CNs900Br.ani differ
diff --git a/public/animations/CNs901BR.ani b/public/animations/CNs901BR.ani
new file mode 100644
index 0000000..623af20
Binary files /dev/null and b/public/animations/CNs901BR.ani differ
diff --git a/public/animations/CNsx11La.ani b/public/animations/CNsx11La.ani
new file mode 100644
index 0000000..19cbb2c
Binary files /dev/null and b/public/animations/CNsx11La.ani differ
diff --git a/public/animations/CNsx11Ni.ani b/public/animations/CNsx11Ni.ani
new file mode 100644
index 0000000..cfd4708
Binary files /dev/null and b/public/animations/CNsx11Ni.ani differ
diff --git a/public/animations/manifest.json b/public/animations/manifest.json
new file mode 100644
index 0000000..8f2c4a8
--- /dev/null
+++ b/public/animations/manifest.json
@@ -0,0 +1,16 @@
+{
+ "walkAnimations": {
+ "xx": "CNs001xx.ani",
+ "Pe": "CNs001Pe.ani",
+ "Ma": "CNs001Ma.ani",
+ "Pa": "CNs001Pa.ani",
+ "Ni": "CNs001Ni.ani",
+ "La": "CNs001La.ani",
+ "Br": "CNs001Br.ani",
+ "Bd": "CNs001Bd.ani",
+ "Pg": "CNs001Pg.ani",
+ "Rd": "CNs001Rd.ani",
+ "Sy": "CNs001Sy.ani",
+ "Sk": "CNs001Sk.ani"
+ }
+}
diff --git a/src/core/formats/AnimationParser.js b/src/core/formats/AnimationParser.js
new file mode 100644
index 0000000..7140556
--- /dev/null
+++ b/src/core/formats/AnimationParser.js
@@ -0,0 +1,217 @@
+/**
+ * Parser for LEGO Island .ani animation files.
+ * Format spec: isle/docs/animation.ksy
+ *
+ * Binary layout:
+ * S32 magic (0x11)
+ * F32 boundingRadius, F32 centerX, F32 centerY, F32 centerZ
+ * S32 hasCameraAnim, S32 unused
+ * U32 numActors
+ * For each actor: U32 nameLen, char[nameLen] name, U32 actorType (if nameLen > 0)
+ * S32 duration (ms)
+ * [optional camera_anim if hasCameraAnim != 0]
+ * tree_node root
+ *
+ * tree_node:
+ * node_data data
+ * U32 numChildren
+ * tree_node[numChildren] children
+ *
+ * node_data:
+ * U32 nameLen, char[nameLen] name (if nameLen > 0)
+ * U16 numTranslationKeys, translation_key[...]
+ * U16 numRotationKeys, rotation_key[...]
+ * U16 numScaleKeys, scale_key[...]
+ * U16 numMorphKeys, morph_key[...]
+ *
+ * Keys share packed time_and_flags (S32): bits 0-23 = time ms, bits 24-31 = flags
+ * translation_key: anim_key + F32 x,y,z
+ * rotation_key: anim_key + F32 angle(w), F32 x, F32 y, F32 z (quaternion)
+ * scale_key: anim_key + F32 x,y,z
+ * morph_key: anim_key + U8 visible
+ */
+
+import { BinaryReader } from './BinaryReader.js';
+
+// Keyframe flags
+const KEY_ACTIVE = 0x01;
+const KEY_NEGATE_ROTATION = 0x02;
+const KEY_SKIP_INTERPOLATION = 0x04;
+
+export class AnimationParser {
+ /**
+ * @param {ArrayBuffer} buffer
+ */
+ constructor(buffer) {
+ this.reader = new BinaryReader(buffer);
+ }
+
+ parse() {
+ const magic = this.reader.readS32();
+ if (magic !== 0x11) {
+ throw new Error(`Invalid animation magic: 0x${magic.toString(16)}, expected 0x11`);
+ }
+
+ const boundingRadius = this.reader.readF32();
+ const centerX = this.reader.readF32();
+ const centerY = this.reader.readF32();
+ const centerZ = this.reader.readF32();
+ const hasCameraAnim = this.reader.readS32();
+ const unused = this.reader.readS32();
+
+ const numActors = this.reader.readU32();
+ const actors = [];
+ for (let i = 0; i < numActors; i++) {
+ actors.push(this.parseActorEntry());
+ }
+
+ const duration = this.reader.readS32();
+
+ let cameraAnim = null;
+ if (hasCameraAnim !== 0) {
+ cameraAnim = this.parseCameraAnim();
+ }
+
+ const rootNode = this.parseTreeNode();
+
+ return {
+ boundingRadius,
+ center: { x: centerX, y: centerY, z: centerZ },
+ actors,
+ duration,
+ cameraAnim,
+ rootNode
+ };
+ }
+
+ parseActorEntry() {
+ const nameLen = this.reader.readU32();
+ if (nameLen === 0) {
+ return { name: '', actorType: 0 };
+ }
+ const name = this.reader.readString(nameLen);
+ const actorType = this.reader.readU32();
+ return { name, actorType };
+ }
+
+ parseCameraAnim() {
+ const numTranslationKeys = this.reader.readU16();
+ const translationKeys = [];
+ for (let i = 0; i < numTranslationKeys; i++) {
+ translationKeys.push(this.parseTranslationKey());
+ }
+
+ const numTargetKeys = this.reader.readU16();
+ const targetKeys = [];
+ for (let i = 0; i < numTargetKeys; i++) {
+ targetKeys.push(this.parseTranslationKey());
+ }
+
+ const numRotationKeys = this.reader.readU16();
+ const rotationKeys = [];
+ for (let i = 0; i < numRotationKeys; i++) {
+ rotationKeys.push(this.parseRotationZKey());
+ }
+
+ return { translationKeys, targetKeys, rotationKeys };
+ }
+
+ parseTreeNode() {
+ const data = this.parseNodeData();
+ const numChildren = this.reader.readU32();
+ const children = [];
+ for (let i = 0; i < numChildren; i++) {
+ children.push(this.parseTreeNode());
+ }
+ return { data, children };
+ }
+
+ parseNodeData() {
+ const nameLen = this.reader.readU32();
+ let name = '';
+ if (nameLen > 0) {
+ name = this.reader.readString(nameLen);
+ }
+
+ const numTranslationKeys = this.reader.readU16();
+ const translationKeys = [];
+ for (let i = 0; i < numTranslationKeys; i++) {
+ translationKeys.push(this.parseTranslationKey());
+ }
+
+ const numRotationKeys = this.reader.readU16();
+ const rotationKeys = [];
+ for (let i = 0; i < numRotationKeys; i++) {
+ rotationKeys.push(this.parseRotationKey());
+ }
+
+ const numScaleKeys = this.reader.readU16();
+ const scaleKeys = [];
+ for (let i = 0; i < numScaleKeys; i++) {
+ scaleKeys.push(this.parseScaleKey());
+ }
+
+ const numMorphKeys = this.reader.readU16();
+ const morphKeys = [];
+ for (let i = 0; i < numMorphKeys; i++) {
+ morphKeys.push(this.parseMorphKey());
+ }
+
+ return { name, translationKeys, rotationKeys, scaleKeys, morphKeys };
+ }
+
+ parseAnimKey() {
+ const timeAndFlags = this.reader.readS32();
+ return {
+ time: timeAndFlags & 0xFFFFFF,
+ flags: (timeAndFlags >>> 24) & 0xFF
+ };
+ }
+
+ parseTranslationKey() {
+ const key = this.parseAnimKey();
+ const x = this.reader.readF32();
+ const y = this.reader.readF32();
+ const z = this.reader.readF32();
+ return { ...key, x, y, z };
+ }
+
+ parseRotationKey() {
+ const key = this.parseAnimKey();
+ const w = this.reader.readF32(); // angle/scalar component
+ const x = this.reader.readF32();
+ const y = this.reader.readF32();
+ const z = this.reader.readF32();
+ return { ...key, w, x, y, z };
+ }
+
+ parseScaleKey() {
+ const key = this.parseAnimKey();
+ const x = this.reader.readF32();
+ const y = this.reader.readF32();
+ const z = this.reader.readF32();
+ return { ...key, x, y, z };
+ }
+
+ parseMorphKey() {
+ const key = this.parseAnimKey();
+ const visible = this.reader.readU8();
+ return { ...key, visible: visible !== 0 };
+ }
+
+ parseRotationZKey() {
+ const key = this.parseAnimKey();
+ const z = this.reader.readF32();
+ return { ...key, z };
+ }
+}
+
+/**
+ * Parse an animation buffer.
+ * @param {ArrayBuffer} buffer
+ * @returns {Object} Parsed animation data
+ */
+export function parseAnimation(buffer) {
+ const parser = new AnimationParser(buffer);
+ return parser.parse();
+}
diff --git a/src/core/formats/SaveGameParser.js b/src/core/formats/SaveGameParser.js
index f3e71fd..494f219 100644
--- a/src/core/formats/SaveGameParser.js
+++ b/src/core/formats/SaveGameParser.js
@@ -100,10 +100,31 @@ export class SaveGameParser {
}
/**
- * Skip character manager data (66 characters * 16 bytes = 1056 bytes)
+ * Parse character manager data (66 characters * 16 bytes = 1056 bytes)
+ * Each character: sound(S32) + move(S32) + mood(U8)
+ * + hatPartNameIndex(U8) + hatNameIndex(U8) + infogronNameIndex(U8)
+ * + armlftNameIndex(U8) + armrtNameIndex(U8) + leglftNameIndex(U8) + legrtNameIndex(U8)
*/
- skipCharacters() {
- this.reader.skip(66 * 16);
+ parseCharacters() {
+ this.parsed.charactersOffset = this.reader.tell();
+ const characters = [];
+
+ for (let i = 0; i < 66; i++) {
+ characters.push({
+ sound: this.reader.readS32(),
+ move: this.reader.readS32(),
+ mood: this.reader.readU8(),
+ hatPartNameIndex: this.reader.readU8(),
+ hatNameIndex: this.reader.readU8(),
+ infogronNameIndex: this.reader.readU8(),
+ armlftNameIndex: this.reader.readU8(),
+ armrtNameIndex: this.reader.readU8(),
+ leglftNameIndex: this.reader.readU8(),
+ legrtNameIndex: this.reader.readU8()
+ });
+ }
+
+ this.parsed.characters = characters;
}
/**
@@ -403,7 +424,7 @@ export class SaveGameParser {
parse() {
this.parseHeader();
this.parseVariables();
- this.skipCharacters();
+ this.parseCharacters();
this.skipPlants();
this.skipBuildings();
this.parseGameStates();
diff --git a/src/core/formats/SaveGameSerializer.js b/src/core/formats/SaveGameSerializer.js
index c39110e..d96f24b 100644
--- a/src/core/formats/SaveGameSerializer.js
+++ b/src/core/formats/SaveGameSerializer.js
@@ -4,7 +4,7 @@
*/
import { SaveGameParser } from './SaveGameParser.js';
import { BinaryWriter } from './BinaryWriter.js';
-import { GameStateTypes, GameStateSizes, Actor, Act1TextureOrder } from '../savegame/constants.js';
+import { GameStateTypes, GameStateSizes, Actor, Act1TextureOrder, CharacterFieldOffsets, CHARACTER_RECORD_SIZE } from '../savegame/constants.js';
/**
* Offsets for header fields
@@ -461,6 +461,40 @@ export class SaveGameSerializer {
return newBuffer;
}
+ /**
+ * Update a character field in the save file
+ * @param {number} characterIndex - Character index (0-65)
+ * @param {string} field - Field name from CharacterFieldOffsets
+ * @param {number} value - New value
+ * @param {ArrayBuffer} [buffer] - Optional buffer to use
+ * @returns {ArrayBuffer|null} - Modified buffer or null on error
+ */
+ updateCharacter(characterIndex, field, value, buffer = null) {
+ if (characterIndex < 0 || characterIndex > 65) {
+ console.error(`Invalid character index: ${characterIndex}`);
+ return null;
+ }
+
+ const fieldOffset = CharacterFieldOffsets[field];
+ if (fieldOffset === undefined) {
+ console.error(`Unknown character field: ${field}`);
+ return null;
+ }
+
+ const workingBuffer = buffer || this.createCopy();
+ const view = new DataView(workingBuffer);
+
+ const offset = this.parsed.charactersOffset + (characterIndex * CHARACTER_RECORD_SIZE) + fieldOffset;
+
+ if (field === 'sound' || field === 'move') {
+ view.setInt32(offset, value, true);
+ } else {
+ view.setUint8(offset, value);
+ }
+
+ return workingBuffer;
+ }
+
/**
* Get the byte offset for a mission score
* @param {string} missionType
diff --git a/src/core/formats/WdbParser.js b/src/core/formats/WdbParser.js
index 3e08deb..c4b3e1c 100644
--- a/src/core/formats/WdbParser.js
+++ b/src/core/formats/WdbParser.js
@@ -23,14 +23,18 @@ export class WdbParser {
}
const globalTexturesSize = this.reader.readU32();
- // Skip global textures for now - BIGCUBE.GIF is in model_data
- this.reader.skip(globalTexturesSize);
+ let globalTextures = [];
+ if (globalTexturesSize > 0) {
+ globalTextures = this.parseTextureInfo();
+ }
const globalPartsSize = this.reader.readU32();
- // Skip global parts
- this.reader.skip(globalPartsSize);
+ let globalParts = null;
+ if (globalPartsSize > 0) {
+ globalParts = this.parseGlobalParts(globalPartsSize);
+ }
- return { worlds, globalTexturesSize, globalPartsSize };
+ return { worlds, globalTexturesSize, globalPartsSize, globalParts, globalTextures };
}
parseWorldEntry() {
@@ -125,6 +129,40 @@ export class WdbParser {
return { parts, textures };
}
+ /**
+ * Parse global parts block (same structure as parsePartData but inline)
+ * @param {number} size - Size of global parts block
+ * @returns {{ parts: Array, textures: Array }}
+ */
+ parseGlobalParts(size) {
+ const startOffset = this.reader.tell();
+ const textureInfoOffset = this.reader.readU32();
+ const numRois = this.reader.readU32();
+ const parts = [];
+
+ for (let i = 0; i < numRois; i++) {
+ const nameLen = this.reader.readU32();
+ const name = this.readCleanString(nameLen);
+ const numLods = this.reader.readU32();
+ const roiInfoOffset = this.reader.readU32();
+
+ const lods = [];
+ for (let j = 0; j < numLods; j++) {
+ lods.push(this.parseLod());
+ }
+
+ parts.push({ name, lods });
+ }
+
+ this.reader.seek(startOffset + textureInfoOffset);
+ const textures = this.parseTextureInfo();
+
+ // Ensure we've consumed the full block
+ this.reader.seek(startOffset + size);
+
+ return { parts, textures };
+ }
+
/**
* Parse model_data blob at specified offset
* @param {number} offset - Absolute file offset
@@ -510,6 +548,21 @@ export function resolveLods(roi, partsMap) {
return [];
}
+/**
+ * Build a parts lookup map from global parts
+ * @param {{ parts: Array }} globalParts - Parsed global parts from WdbParser
+ * @returns {Map} - Map of part name (lowercase) -> part data
+ */
+export function buildGlobalPartsMap(globalParts) {
+ const partsMap = new Map();
+ if (!globalParts || !globalParts.parts) return partsMap;
+
+ for (const part of globalParts.parts) {
+ partsMap.set(part.name.toLowerCase(), part);
+ }
+ return partsMap;
+}
+
/**
* Build a parts lookup map from a world's parts array
* @param {WdbParser} parser - Parser instance for reading part data
diff --git a/src/core/formats/index.js b/src/core/formats/index.js
index a6d3ca4..aaddc97 100644
--- a/src/core/formats/index.js
+++ b/src/core/formats/index.js
@@ -7,7 +7,7 @@ export { BinaryReader } from './BinaryReader.js';
export { BinaryWriter } from './BinaryWriter.js';
// WDB format
-export { WdbParser, findRoi } from './WdbParser.js';
+export { WdbParser, findRoi, buildGlobalPartsMap } from './WdbParser.js';
// Save game format
export { SaveGameParser, parseSaveGame } from './SaveGameParser.js';
@@ -19,3 +19,6 @@ export { PlayersSerializer, createPlayersSerializer } from './PlayersSerializer.
// Texture format
export { parseTex } from './TexParser.js';
+
+// Animation format
+export { AnimationParser, parseAnimation } from './AnimationParser.js';
diff --git a/src/core/rendering/ActorRenderer.js b/src/core/rendering/ActorRenderer.js
new file mode 100644
index 0000000..d96e890
--- /dev/null
+++ b/src/core/rendering/ActorRenderer.js
@@ -0,0 +1,881 @@
+import * as THREE from 'three';
+import { ActorLODs, ActorLODFlags, ActorInfoInit, partToLODIndex } from '../savegame/actorConstants.js';
+import { parseAnimation } from '../formats/AnimationParser.js';
+
+// Extended LEGO colors (includes brown and lt grey not in the vehicle editor)
+const ExtendedLegoColors = Object.freeze({
+ 'lego black': { r: 0x21, g: 0x21, b: 0x21 },
+ 'lego blue': { r: 0x00, g: 0x54, b: 0x8c },
+ 'lego green': { r: 0x00, g: 0x78, b: 0x2d },
+ 'lego red': { r: 0xcb, g: 0x12, b: 0x20 },
+ 'lego white': { r: 0xfa, g: 0xfa, b: 0xfa },
+ 'lego yellow': { r: 0xff, g: 0xb9, b: 0x00 },
+ 'lego brown': { r: 0x4a, g: 0x23, b: 0x00 },
+ 'lego lt grey': { r: 0xc0, g: 0xc0, b: 0xc0 }
+});
+
+/**
+ * Map actor index to animation suffix index (from g_characters[].m_unk0x16).
+ * This maps the 66 ActorInfoInit indices to the g_cycles row index.
+ */
+const ACTOR_SUFFIX_INDEX = (() => {
+ // Default all to 0 (xx)
+ const map = new Array(66).fill(0);
+ map[0] = 1; // pepper → Pe
+ map[1] = 2; // mama → Ma
+ map[2] = 3; // papa → Pa
+ map[3] = 4; // nick → Ni
+ map[4] = 5; // laura → La
+ map[5] = 0; // infoman → xx (not in g_characters, uses default)
+ map[6] = 6; // brickstr → Br
+ // 7-35: all 0 (xx) — generic NPCs
+ // Note: g_characters indices don't perfectly align with ActorInfoInit indices
+ // for the NPCs after brickstr. The g_characters array has 47 entries
+ // matching by name. We map the special ones:
+ map[37] = 9; // rd → Rd (g_characters index 36)
+ map[38] = 8; // pg → Pg (g_characters index 37)
+ map[39] = 7; // bd → Bd (g_characters index 38)
+ map[40] = 10; // sy → Sy (g_characters index 39)
+ map[56] = 1; // pep → Pe (same as pepper)
+ return map;
+})();
+
+/**
+ * Suffix names indexed by g_cycles row index.
+ */
+const SUFFIX_NAMES = ['xx', 'Pe', 'Ma', 'Pa', 'Ni', 'La', 'Br', 'Bd', 'Pg', 'Rd', 'Sy'];
+
+/**
+ * g_cycles[11][17] — animation name table from legoanimationmanager.cpp.
+ * Rows = character type suffix index, columns = sound + 4 * move (0-16).
+ */
+const G_CYCLES = [
+ // 0: xx
+ ['CNs001xx','CNs002xx','CNs003xx','CNs004xx','CNs005xx','CNs007xx','CNs006xx','CNs008xx','CNs009xx','CNs010xx','CNs011xx','CNs012xx',null,null,null,null,null],
+ // 1: Pe
+ ['CNs001Pe','CNs002Pe','CNs003Pe','CNs004Pe','CNs005Pe','CNs007Pe','CNs006Pe','CNs008Pe','CNs009Pe','CNs010Pe','CNs001Sk',null,null,null,null,null,null], // CNs001Sk = skateboard
+ // 2: Ma
+ ['CNs001Ma','CNs002Ma','CNs003Ma','CNs004Ma','CNs005Ma','CNs007Ma','CNs006Ma','CNs008Ma','CNs009Ma','CNs010Ma','CNs0x4Ma',null,null,'CNs011Ma','CNs012Ma','CNs013Ma',null],
+ // 3: Pa
+ ['CNs001Pa','CNs002Pa','CNs003Pa','CNs004Pa','CNs005Pa','CNs007Pa','CNs006Pa','CNs008Pa','CNs009Pa','CNs010Pa','CNs0x4Pa',null,null,'CNs011Pa','CNs012Pa','CNs013Pa',null],
+ // 4: Ni
+ ['CNs001Ni','CNs002Ni','CNs003Ni','CNs004Ni','CNs005Ni','CNs007Ni','CNs006Ni','CNs008Ni','CNs009Ni','CNs010Ni','CNs011Ni','CNsx11Ni',null,null,null,null,null],
+ // 5: La
+ ['CNs001La','CNs002La','CNs003La','CNs004La','CNs005La','CNs007La','CNs006La','CNs008La','CNs009La','CNs010La','CNs011La','CNsx11La',null,null,null,null,null],
+ // 6: Br
+ ['CNs001Br','CNs002Br','CNs003Br','CNs004Br','CNs005Br','CNs007Br','CNs006Br','CNs008Br','CNs009Br','CNs010Br','CNs011Br','CNs900Br','CNs901BR','CNs011Br','CNs012Br','CNs013Br','CNs014Br'],
+ // 7: Bd
+ ['CNs001xx','CNs002xx','CNs003xx','CNs004xx','CNs005xx','CNs007xx','CNs006xx','CNs008xx','CNs009xx','CNs010xx','CNs001Bd','CNs012xx',null,null,null,null,null],
+ // 8: Pg
+ ['CNs001xx','CNs002xx','CNs003xx','CNs004xx','CNs005xx','CNs007xx','CNs006xx','CNs008xx','CNs009xx','CNs010xx','CNs001Pg','CNs012xx',null,null,null,null,null],
+ // 9: Rd
+ ['CNs001xx','CNs002xx','CNs003xx','CNs004xx','CNs005xx','CNs007xx','CNs006xx','CNs008xx','CNs009xx','CNs010xx','CNs001Rd','CNs012xx',null,null,null,null,null],
+ // 10: Sy
+ ['CNs001xx','CNs002xx','CNs003xx','CNs004xx','CNs005xx','CNs007xx','CNs006xx','CNs008xx','CNs009xx','CNs010xx','CNs001Sy','CNs012xx',null,null,null,null,null],
+];
+
+/**
+ * Map ActorLOD names (used in our part hierarchy) to animation node names.
+ * Animation files use uppercase names like "BODY", "HEAD", "LEG-RT", etc.
+ */
+const PART_NAME_TO_ANIM_NODE = {
+ 'body': 'BODY',
+ 'infohat': 'INFOHAT',
+ 'infogron': 'INFOGRON',
+ 'head': 'HEAD',
+ 'arm-lft': 'ARM-LFT',
+ 'arm-rt': 'ARM-RT',
+ 'claw-lft': 'CLAW-LFT',
+ 'claw-rt': 'CLAW-RT',
+ 'leg-lft': 'LEG-LFT',
+ 'leg-rt': 'LEG-RT'
+};
+
+/**
+ * Renderer for full LEGO characters assembled from WDB global parts.
+ * Mirrors the game's LegoCharacterManager::CreateActorROI logic.
+ */
+export class ActorRenderer {
+ constructor(canvas) {
+ this.canvas = canvas;
+ this.animating = false;
+ this.modelGroup = null;
+ this.partGroups = []; // 10 part groups for click targeting
+ this.textures = new Map();
+ this.clock = new THREE.Clock();
+ this.mixer = null;
+ this.currentAction = null;
+ this.animationCache = new Map(); // suffix → parsed animation data
+
+ this.scene = new THREE.Scene();
+
+ this.camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
+ this.camera.position.set(2, 0.8, 3.5);
+ this.camera.lookAt(0, 0.2, 0);
+
+ this.renderer = new THREE.WebGLRenderer({
+ canvas,
+ antialias: true,
+ alpha: true
+ });
+ this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+ this.renderer.setClearColor(0x000000, 0);
+
+ this.raycaster = new THREE.Raycaster();
+
+ this.setupLighting();
+ }
+
+ setupLighting() {
+ const ambient = new THREE.AmbientLight(0xffffff, 0.8);
+ this.scene.add(ambient);
+
+ const sunLight = new THREE.DirectionalLight(0xffffff, 0.6);
+ sunLight.position.set(1, 2, 3);
+ this.scene.add(sunLight);
+ }
+
+ /**
+ * Create a Three.js texture from parsed texture data
+ */
+ createTexture(textureData) {
+ const canvas = document.createElement('canvas');
+ canvas.width = textureData.width;
+ canvas.height = textureData.height;
+ const ctx = canvas.getContext('2d');
+
+ const imageData = ctx.createImageData(textureData.width, textureData.height);
+ for (let i = 0; i < textureData.pixels.length; i++) {
+ const colorIdx = textureData.pixels[i];
+ const color = textureData.palette[colorIdx] || { r: 0, g: 0, b: 0 };
+ imageData.data[i * 4 + 0] = color.r;
+ imageData.data[i * 4 + 1] = color.g;
+ imageData.data[i * 4 + 2] = color.b;
+ imageData.data[i * 4 + 3] = 255;
+ }
+ ctx.putImageData(imageData, 0, 0);
+
+ const texture = new THREE.CanvasTexture(canvas);
+ texture.minFilter = THREE.NearestFilter;
+ texture.magFilter = THREE.NearestFilter;
+ texture.wrapS = THREE.RepeatWrapping;
+ texture.wrapT = THREE.RepeatWrapping;
+ return texture;
+ }
+
+ /**
+ * Load a full actor from global parts.
+ * @param {number} actorIndex - Index into ActorInfoInit (0-65)
+ * @param {Array} characters - Parsed character state from save file (66 entries)
+ * @param {Map} globalPartsMap - Name→part lookup for global parts
+ * @param {Array} globalTextures - Global texture list from WDB
+ */
+ loadActor(actorIndex, characters, globalPartsMap, globalTextures) {
+ this.clearModel();
+
+ const actorInfo = ActorInfoInit[actorIndex];
+ if (!actorInfo) return;
+
+ const charState = characters ? characters[actorIndex] : null;
+
+ // Build texture lookup
+ this.textures.clear();
+ if (globalTextures) {
+ for (const tex of globalTextures) {
+ if (tex.name) {
+ this.textures.set(tex.name.toLowerCase(), this.createTexture(tex));
+ }
+ }
+ }
+
+ this.modelGroup = new THREE.Group();
+ this.partGroups = [];
+
+ // Assemble 10 body parts (matching CreateActorROI loop: i=0..9 maps to ActorLODs[1..10])
+ for (let i = 0; i < 10; i++) {
+ const lodIdx = partToLODIndex[i];
+ const actorLOD = ActorLODs[lodIdx];
+ const part = actorInfo.parts[i];
+
+ // Resolve part name for body (i=0) and hat (i=1)
+ let partName;
+ if (i === 0 || i === 1) {
+ partName = this.resolvePartName(part, charState, i);
+ } else {
+ partName = actorLOD.parentName;
+ }
+
+ if (!partName) continue;
+
+ // Find the part's LOD data in global parts
+ const partData = globalPartsMap.get(partName.toLowerCase());
+ if (!partData) continue;
+
+ const partGroup = new THREE.Group();
+ partGroup.userData.partIndex = i;
+ partGroup.userData.partName = partName;
+ partGroup.userData.lodName = actorLOD.name; // for animation matching
+ // Name used by Three.js PropertyBinding to match animation tracks
+ partGroup.name = `part_${actorLOD.name}`;
+
+ // Resolve color/texture for this part
+ const resolvedName = this.resolveNameValue(part, charState, i);
+
+ // Create meshes from LODs
+ const lods = partData.lods || [];
+ if (lods.length > 0) {
+ const lod = lods[lods.length - 1]; // Highest quality
+ this.createPartMeshes(lod, actorLOD, part, resolvedName, i, partGroup);
+ }
+
+ // Position the part using ActorLOD transform
+ this.applyPartTransform(partGroup, actorLOD);
+
+ this.modelGroup.add(partGroup);
+ this.partGroups[i] = partGroup;
+ }
+
+ this.centerAndScaleModel();
+ // Rotate 180° around Y so actor faces the camera (negating X for
+ // left-to-right-handed conversion flips the facing direction)
+ this.modelGroup.rotation.y = Math.PI;
+ this.scene.add(this.modelGroup);
+
+ // Load and start animation based on move/sound
+ const move = charState?.move ?? 0;
+ const sound = charState?.sound ?? 0;
+ this.loadAnimationForActor(actorIndex, move, sound);
+
+ this.renderer.render(this.scene, this.camera);
+ }
+
+ /**
+ * Resolve which part geometry to use (body variant or hat type).
+ * For body (i=0): partNameIndices[charState.hatPartNameIndex or default] → bodyPartNames index
+ * For hat (i=1): partNameIndices[charState.hatPartNameIndex or default] → hatPartNames
+ * The save file stores the index into the partNameIndices array.
+ */
+ resolvePartName(part, charState, partIdx) {
+ if (!part.partNameIndices || !part.partNames) return null;
+
+ let nameIdx = part.partNameIndex; // default from actorInfoInit
+
+ // For body (part 0): the partNameIndex selects from bodyPartNames
+ // For hat (part 1): the partNameIndex selects from hatPartNames
+ // The save file overrides hatPartNameIndex for part 1 only
+ if (partIdx === 1 && charState) {
+ nameIdx = charState.hatPartNameIndex;
+ }
+
+ if (nameIdx >= part.partNameIndices.length) {
+ nameIdx = part.partNameIndex;
+ }
+
+ const resolvedIdx = part.partNameIndices[nameIdx];
+ if (resolvedIdx === undefined || resolvedIdx >= part.partNames.length) {
+ return part.partNames[part.partNameIndices[part.partNameIndex]];
+ }
+
+ return part.partNames[resolvedIdx];
+ }
+
+ /**
+ * Resolve the color or texture name for a part.
+ * Uses nameIndices[nameIndex] → names[] (colorAliases or faceTextures/chestTextures)
+ */
+ resolveNameValue(part, charState, partIdx) {
+ if (!part.nameIndices || !part.names) return null;
+
+ let nameIdx = part.nameIndex; // default
+
+ // Save file overrides specific fields
+ if (charState) {
+ switch (partIdx) {
+ case 1: nameIdx = charState.hatNameIndex; break; // hat color
+ case 2: nameIdx = charState.infogronNameIndex; break; // infogron color
+ case 4: nameIdx = charState.armlftNameIndex; break; // arm left
+ case 5: nameIdx = charState.armrtNameIndex; break; // arm right
+ case 8: nameIdx = charState.leglftNameIndex; break; // leg left
+ case 9: nameIdx = charState.legrtNameIndex; break; // leg right
+ }
+ }
+
+ if (nameIdx >= part.nameIndices.length) {
+ nameIdx = part.nameIndex;
+ }
+
+ const resolvedIdx = part.nameIndices[nameIdx];
+ if (resolvedIdx === undefined || resolvedIdx >= part.names.length) {
+ return part.names[part.nameIndices[part.nameIndex]];
+ }
+
+ return part.names[resolvedIdx];
+ }
+
+ /**
+ * Create meshes for a single body part.
+ */
+ createPartMeshes(lod, actorLOD, part, resolvedName, partIdx, group) {
+ const useTexture = (actorLOD.flags & ActorLODFlags.USE_TEXTURE) !== 0;
+ const useColor = (actorLOD.flags & ActorLODFlags.USE_COLOR) !== 0;
+
+ // Special case: body part (i=0) with partNameIndex 0 uses color instead of texture
+ // (matches the C++ condition: i != 0 || part.m_partNameIndices[part.m_partNameIndex] != 0)
+ const bodyUsesDefaultGeom = partIdx === 0 && part.partNameIndices &&
+ part.partNameIndices[part.partNameIndex] === 0;
+
+ let partColor = null;
+ let partTexture = null;
+
+ if (useTexture && !bodyUsesDefaultGeom) {
+ // Look up texture by resolved name
+ const texName = resolvedName?.toLowerCase();
+ if (texName && this.textures.has(texName)) {
+ partTexture = this.textures.get(texName);
+ }
+ }
+
+ if ((useColor || bodyUsesDefaultGeom) && !partTexture) {
+ // Resolve LEGO color
+ const colorEntry = ExtendedLegoColors[resolvedName] || ExtendedLegoColors['lego white'];
+ partColor = new THREE.Color(colorEntry.r / 255, colorEntry.g / 255, colorEntry.b / 255);
+ }
+
+ for (const mesh of lod.meshes) {
+ const geometry = this.createGeometry(mesh, lod);
+ if (!geometry) continue;
+
+ // Check for per-mesh texture from the WDB geometry
+ let meshTexture = null;
+ const meshTexName = mesh.properties?.textureName?.toLowerCase();
+ if (meshTexName && this.textures.has(meshTexName)) {
+ meshTexture = this.textures.get(meshTexName);
+ }
+
+ let material;
+ if (partTexture) {
+ material = new THREE.MeshLambertMaterial({
+ map: partTexture,
+ side: THREE.DoubleSide,
+ color: 0xffffff
+ });
+ } else if (meshTexture) {
+ material = new THREE.MeshLambertMaterial({
+ map: meshTexture,
+ side: THREE.DoubleSide,
+ color: 0xffffff
+ });
+ } else if (partColor) {
+ material = new THREE.MeshLambertMaterial({
+ color: partColor,
+ side: THREE.DoubleSide
+ });
+ } else {
+ const meshColor = mesh.properties?.color || { r: 128, g: 128, b: 128 };
+ material = new THREE.MeshLambertMaterial({
+ color: new THREE.Color(meshColor.r / 255, meshColor.g / 255, meshColor.b / 255),
+ side: THREE.DoubleSide
+ });
+ }
+
+ const threeMesh = new THREE.Mesh(geometry, material);
+ group.add(threeMesh);
+ }
+ }
+
+ /**
+ * Create geometry from mesh data (same approach as VehiclePartRenderer)
+ */
+ createGeometry(mesh, lod) {
+ if (!mesh.polygonIndices || mesh.polygonIndices.length === 0) return null;
+
+ const hasTexture = mesh.textureIndices && mesh.textureIndices.length > 0;
+
+ const vertexIndicesPacked = [];
+ for (const poly of mesh.polygonIndices) {
+ vertexIndicesPacked.push(poly.a, poly.b, poly.c);
+ }
+
+ const textureIndicesFlat = [];
+ if (hasTexture) {
+ for (const texPoly of mesh.textureIndices) {
+ textureIndicesFlat.push(texPoly.a, texPoly.b, texPoly.c);
+ }
+ }
+
+ const meshVertices = [];
+ const meshNormals = [];
+ const meshUvs = [];
+ const indices = [];
+
+ for (let i = 0; i < vertexIndicesPacked.length; i++) {
+ const packed = vertexIndicesPacked[i];
+
+ if ((packed & 0x80000000) !== 0) {
+ indices.push(meshVertices.length);
+
+ const gv = packed & 0xFFFF;
+ const v = lod.vertices[gv] || { x: 0, y: 0, z: 0 };
+ meshVertices.push([-v.x, v.y, v.z]);
+
+ const gn = (packed >>> 16) & 0x7fff;
+ const n = lod.normals[gn] || { x: 0, y: 1, z: 0 };
+ meshNormals.push([-n.x, n.y, n.z]);
+
+ if (hasTexture && lod.textureVertices && lod.textureVertices.length > 0) {
+ const tex = textureIndicesFlat[i];
+ const uv = lod.textureVertices[tex] || { u: 0, v: 0 };
+ meshUvs.push([uv.u, 1 - uv.v]);
+ }
+ } else {
+ indices.push(packed & 0xFFFF);
+ }
+ }
+
+ // Reverse face winding
+ for (let i = 0; i < indices.length; i += 3) {
+ const temp = indices[i];
+ indices[i] = indices[i + 2];
+ indices[i + 2] = temp;
+ }
+
+ const geometry = new THREE.BufferGeometry();
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(meshVertices.flat(), 3));
+ geometry.setAttribute('normal', new THREE.Float32BufferAttribute(meshNormals.flat(), 3));
+ geometry.setIndex(indices);
+
+ if (hasTexture && meshUvs.length > 0) {
+ geometry.setAttribute('uv', new THREE.Float32BufferAttribute(meshUvs.flat(), 2));
+ }
+
+ return geometry;
+ }
+
+ /**
+ * Apply position/direction/up transform from ActorLOD data.
+ * The game uses CalcLocalTransform with direction/up vectors.
+ */
+ applyPartTransform(group, actorLOD) {
+ const pos = actorLOD.position;
+ const dir = actorLOD.direction;
+ const up = actorLOD.up;
+
+ // Build right vector = cross(dir, up)
+ const right = new THREE.Vector3(
+ dir[1] * up[2] - dir[2] * up[1],
+ dir[2] * up[0] - dir[0] * up[2],
+ dir[0] * up[1] - dir[1] * up[0]
+ );
+
+ // Negate X for our coordinate system (matching VehiclePartRenderer's -v.x)
+ group.position.set(-pos[0], pos[1], pos[2]);
+ }
+
+ /**
+ * Update a single part's color without full reload
+ */
+ updatePartColor(partIndex, colorName) {
+ const partGroup = this.partGroups[partIndex];
+ if (!partGroup) return;
+
+ const colorEntry = ExtendedLegoColors[colorName] || ExtendedLegoColors['lego white'];
+ const threeColor = new THREE.Color(colorEntry.r / 255, colorEntry.g / 255, colorEntry.b / 255);
+
+ partGroup.traverse((child) => {
+ if (child instanceof THREE.Mesh && child.material && !child.material.map) {
+ child.material.color = threeColor;
+ }
+ });
+
+ this.renderer.render(this.scene, this.camera);
+ }
+
+ /**
+ * Get which body part was clicked
+ * @returns {number} Part index (0-9) or -1 if nothing hit
+ */
+ getClickedPart(mouseEvent) {
+ if (!this.modelGroup) return -1;
+
+ const rect = this.canvas.getBoundingClientRect();
+ const mouse = new THREE.Vector2(
+ ((mouseEvent.clientX - rect.left) / rect.width) * 2 - 1,
+ -((mouseEvent.clientY - rect.top) / rect.height) * 2 + 1
+ );
+
+ this.raycaster.setFromCamera(mouse, this.camera);
+
+ for (let i = 0; i < this.partGroups.length; i++) {
+ const partGroup = this.partGroups[i];
+ if (!partGroup) continue;
+
+ const meshes = [];
+ partGroup.traverse((child) => {
+ if (child instanceof THREE.Mesh) meshes.push(child);
+ });
+
+ const intersects = this.raycaster.intersectObjects(meshes);
+ if (intersects.length > 0) return i;
+ }
+
+ return -1;
+ }
+
+ // ─── Animation System ────────────────────────────────────────────
+
+ /**
+ * Load and start animation for the given actor using g_cycles table.
+ * Animation index = sound + 4 * move. Pre-computes world-space transforms
+ * by evaluating the animation tree hierarchically, then plays via AnimationMixer.
+ * Falls back to Y-axis rotation if unavailable.
+ */
+ async loadAnimationForActor(actorIndex, move = 0, sound = 0) {
+ if (!this.modelGroup) return;
+
+ this.stopAnimation();
+
+ const suffixIdx = ACTOR_SUFFIX_INDEX[actorIndex] ?? 0;
+ const animIdx = sound + 4 * move;
+ const animName = G_CYCLES[suffixIdx]?.[animIdx];
+
+ if (!animName) return; // null entry in g_cycles — no animation for this combo
+
+ try {
+ const animData = await this.fetchAnimationByName(animName);
+ if (!animData || !this.modelGroup) return;
+
+ // Build animation node name → part group lookup
+ const nodeToPartGroup = new Map();
+ for (let i = 0; i < this.partGroups.length; i++) {
+ const pg = this.partGroups[i];
+ if (!pg) continue;
+ const lodName = pg.userData.lodName;
+ const animName = PART_NAME_TO_ANIM_NODE[lodName];
+ if (animName) {
+ nodeToPartGroup.set(animName.toLowerCase(), pg);
+ }
+ }
+
+ const tracks = this.buildHierarchicalTracks(animData, nodeToPartGroup);
+ if (tracks.length === 0) return;
+
+ const clip = new THREE.AnimationClip('walk', -1, tracks);
+ this.mixer = new THREE.AnimationMixer(this.modelGroup);
+ this.currentAction = this.mixer.clipAction(clip);
+ this.currentAction.play();
+ } catch (e) {
+ // Animation unavailable — fall back to rotation (handled in animate())
+ }
+ }
+
+ /**
+ * Fetch and parse an animation file by name (e.g. "CNs001xx"), with caching.
+ */
+ async fetchAnimationByName(animName) {
+ if (this.animationCache.has(animName)) {
+ return this.animationCache.get(animName);
+ }
+
+ const response = await fetch(`/animations/${animName}.ani`);
+ if (!response.ok) return null;
+
+ const buffer = await response.arrayBuffer();
+ const animData = parseAnimation(buffer);
+ this.animationCache.set(animName, animData);
+ return animData;
+ }
+
+ /**
+ * Build world-space keyframe tracks by evaluating the animation tree
+ * hierarchically. At each unique keyframe time, walks the tree composing
+ * parent * child transforms via matrix multiplication, then decomposes
+ * to world-space position/quaternion for each part.
+ */
+ buildHierarchicalTracks(animData, nodeToPartGroup) {
+ const duration = animData.duration;
+
+ // Collect all unique keyframe times from the tree
+ const timesSet = new Set([0]);
+ this.collectKeyframeTimes(animData.rootNode, timesSet);
+ const times = [...timesSet].filter(t => t <= duration).sort((a, b) => a - b);
+
+ // For each time, evaluate the full tree and store world-space transforms
+ const valueMap = new Map();
+ const identity = new THREE.Matrix4();
+
+ for (const time of times) {
+ this.evaluateNode(animData.rootNode, time, identity, nodeToPartGroup, valueMap, true);
+ }
+
+ // Convert to Three.js KeyframeTracks
+ const timesSec = times.map(t => t / 1000);
+ const tracks = [];
+ for (const [name, values] of valueMap) {
+ if (name.endsWith('.position')) {
+ tracks.push(new THREE.VectorKeyframeTrack(name, timesSec, values));
+ } else if (name.endsWith('.quaternion')) {
+ tracks.push(new THREE.QuaternionKeyframeTrack(name, timesSec, values));
+ }
+ }
+ return tracks;
+ }
+
+ /**
+ * Recursively collect all unique keyframe times from the animation tree.
+ */
+ collectKeyframeTimes(node, timesSet) {
+ const data = node.data;
+ for (const key of data.translationKeys) timesSet.add(key.time);
+ for (const key of data.rotationKeys) timesSet.add(key.time);
+ for (const key of data.scaleKeys) timesSet.add(key.time);
+ for (const child of node.children) {
+ this.collectKeyframeTimes(child, timesSet);
+ }
+ }
+
+ /**
+ * Evaluate a single animation node at a given time, composing its local
+ * transform with the parent's world matrix. If the node maps to a part
+ * group, stores the decomposed world-space position and quaternion.
+ * Recurses into children with the composed matrix.
+ */
+ evaluateNode(node, time, parentMatrix, nodeToPartGroup, valueMap, isRoot = false) {
+ const data = node.data;
+ let mat = new THREE.Matrix4();
+
+ // 1. Scale (applied first)
+ if (data.scaleKeys.length > 0) {
+ const scale = this.interpolateVertex(data.scaleKeys, time, false);
+ if (scale) {
+ mat.scale(scale);
+ }
+ if (data.rotationKeys.length > 0) {
+ mat = this.evaluateRotation(data.rotationKeys, time).multiply(mat);
+ }
+ } else if (data.rotationKeys.length > 0) {
+ mat = this.evaluateRotation(data.rotationKeys, time);
+ }
+
+ // 2. Translation (skip on root node so the actor walks in place)
+ if (!isRoot && data.translationKeys.length > 0) {
+ const vertex = this.interpolateVertex(data.translationKeys, time, true);
+ if (vertex) {
+ mat.elements[12] += vertex.x;
+ mat.elements[13] += vertex.y;
+ mat.elements[14] += vertex.z;
+ }
+ }
+
+ // 3. Compose with parent: world = parent * local
+ mat = parentMatrix.clone().multiply(mat);
+
+ // 4. If this node maps to a part group, decompose and store
+ const nodeName = data.name?.toLowerCase();
+ if (nodeName) {
+ const partGroup = nodeToPartGroup.get(nodeName);
+ if (partGroup) {
+ const position = new THREE.Vector3();
+ const quaternion = new THREE.Quaternion();
+ const scale = new THREE.Vector3();
+ mat.decompose(position, quaternion, scale);
+
+ if (Math.abs(scale.x) < 1e-8 || Math.abs(scale.y) < 1e-8 || Math.abs(scale.z) < 1e-8) {
+ quaternion.identity();
+ }
+
+ const trackName = partGroup.name;
+ this.pushValues(valueMap, `${trackName}.position`, position.toArray());
+ this.pushValues(valueMap, `${trackName}.quaternion`, [quaternion.x, quaternion.y, quaternion.z, quaternion.w]);
+ }
+ }
+
+ // 5. Recurse into children
+ for (const child of node.children) {
+ this.evaluateNode(child, time, mat, nodeToPartGroup, valueMap);
+ }
+ }
+
+ /**
+ * Evaluate rotation keyframes at a given time.
+ * Handles slerp interpolation between keyframes with flag-based control.
+ * Coordinate conversion: game (w,x,y,z) → Three.js with X negated.
+ */
+ evaluateRotation(keys, time) {
+ const { before, after } = this.getBeforeAndAfter(keys, time);
+
+ const toQuat = (key) => new THREE.Quaternion(-key.x, key.y, key.z, key.w);
+
+ if (!after) {
+ if (before.flags & 0x01) {
+ return new THREE.Matrix4().makeRotationFromQuaternion(toQuat(before));
+ }
+ return new THREE.Matrix4();
+ }
+
+ if ((before.flags & 0x01) || (after.flags & 0x01)) {
+ const beforeQ = toQuat(before);
+
+ // Flag 0x04: skip interpolation, use before value
+ if (after.flags & 0x04) {
+ return new THREE.Matrix4().makeRotationFromQuaternion(beforeQ);
+ }
+
+ let afterQ = toQuat(after);
+ // Flag 0x02: negate the after quaternion before slerp
+ if (after.flags & 0x02) {
+ afterQ.set(-afterQ.x, -afterQ.y, -afterQ.z, -afterQ.w);
+ }
+
+ const t = (time - before.time) / (after.time - before.time);
+ const result = new THREE.Quaternion().slerpQuaternions(beforeQ, afterQ, t);
+ return new THREE.Matrix4().makeRotationFromQuaternion(result);
+ }
+
+ return new THREE.Matrix4();
+ }
+
+ /**
+ * Interpolate translation or scale keyframes at a given time.
+ * For translation: negates X for coordinate system conversion.
+ * For scale: no negation.
+ */
+ interpolateVertex(keys, time, isTranslation) {
+ const { before, after } = this.getBeforeAndAfter(keys, time);
+
+ const toVec = (key) => isTranslation
+ ? new THREE.Vector3(-key.x, key.y, key.z)
+ : new THREE.Vector3(key.x, key.y, key.z);
+
+ if (!after) {
+ if (isTranslation && !(before.flags & 0x01)) {
+ // Check if vertex is non-zero (matching reference behavior)
+ if (Math.abs(before.x) < 1e-5 && Math.abs(before.y) < 1e-5 && Math.abs(before.z) < 1e-5) {
+ return null;
+ }
+ }
+ return toVec(before);
+ }
+
+ if (isTranslation && !(before.flags & 0x01) && !(after.flags & 0x01)) {
+ // Both inactive — check if vertices are non-zero
+ const bNonZero = Math.abs(before.x) > 1e-5 || Math.abs(before.y) > 1e-5 || Math.abs(before.z) > 1e-5;
+ const aNonZero = Math.abs(after.x) > 1e-5 || Math.abs(after.y) > 1e-5 || Math.abs(after.z) > 1e-5;
+ if (!bNonZero && !aNonZero) return null;
+ }
+
+ const t = (time - before.time) / (after.time - before.time);
+ return new THREE.Vector3().lerpVectors(toVec(before), toVec(after), t);
+ }
+
+ /**
+ * Find the keyframes immediately before and after the given time.
+ */
+ getBeforeAndAfter(keys, time) {
+ let idx = keys.findIndex(k => k.time > time);
+ if (idx < 0) idx = keys.length;
+ const before = keys[Math.max(0, idx - 1)];
+ return { before, after: keys[idx] || null };
+ }
+
+ /**
+ * Append values to a named entry in the value map.
+ */
+ pushValues(map, key, values) {
+ const existing = map.get(key);
+ if (!existing) {
+ map.set(key, [...values]);
+ } else {
+ existing.push(...values);
+ }
+ }
+
+ stopAnimation() {
+ if (this.currentAction) {
+ this.currentAction.stop();
+ this.currentAction = null;
+ }
+ if (this.mixer) {
+ this.mixer.stopAllAction();
+ this.mixer = null;
+ }
+ }
+
+ // ─── Scene Management ────────────────────────────────────────────
+
+ centerAndScaleModel() {
+ if (!this.modelGroup) return;
+
+ const box = new THREE.Box3().setFromObject(this.modelGroup);
+ const center = box.getCenter(new THREE.Vector3());
+ const size = box.getSize(new THREE.Vector3());
+
+ this.modelGroup.position.sub(center);
+
+ const maxDim = Math.max(size.x, size.y, size.z);
+ if (maxDim > 0) {
+ const scale = 1.8 / maxDim;
+ this.modelGroup.scale.setScalar(scale);
+ }
+ }
+
+ clearModel() {
+ this.stopAnimation();
+
+ if (this.modelGroup) {
+ this.modelGroup.traverse((child) => {
+ if (child instanceof THREE.Mesh) {
+ child.geometry?.dispose();
+ child.material?.dispose();
+ }
+ });
+ this.scene.remove(this.modelGroup);
+ this.modelGroup = null;
+ }
+ this.partGroups = [];
+
+ for (const texture of this.textures.values()) {
+ texture.dispose();
+ }
+ this.textures.clear();
+ }
+
+ start() {
+ this.animating = true;
+ this.clock.start();
+ this.animate();
+ }
+
+ stop() {
+ this.animating = false;
+ }
+
+ animate = () => {
+ if (!this.animating) return;
+ requestAnimationFrame(this.animate);
+
+ const delta = this.clock.getDelta();
+
+ if (this.mixer) {
+ this.mixer.update(delta);
+ } else if (this.modelGroup) {
+ // Fallback: rotate if no animation loaded
+ this.modelGroup.rotation.y += 0.01;
+ }
+
+ this.renderer.render(this.scene, this.camera);
+ }
+
+ resize(width, height) {
+ this.camera.aspect = width / height;
+ this.camera.updateProjectionMatrix();
+ this.renderer.setSize(width, height, false);
+ }
+
+ dispose() {
+ this.animating = false;
+ this.stopAnimation();
+ this.clearModel();
+ this.renderer?.dispose();
+ this.animationCache.clear();
+ }
+}
diff --git a/src/core/savegame/actorConstants.js b/src/core/savegame/actorConstants.js
new file mode 100644
index 0000000..008a1a3
--- /dev/null
+++ b/src/core/savegame/actorConstants.js
@@ -0,0 +1,669 @@
+/**
+ * Actor data constants ported from LEGO1 source:
+ * isle/LEGO1/lego/legoomni/src/common/legoactors.cpp
+ * isle/LEGO1/lego/legoomni/include/legoactors.h
+ */
+
+// LegoActorLOD flags
+export const ActorLODFlags = Object.freeze({
+ USE_TEXTURE: 0x01,
+ USE_COLOR: 0x02
+});
+
+// LegoActorLODs enum — indices into ActorLODs[]
+export const ActorLODIndex = Object.freeze({
+ TOP: 0,
+ BODY: 1,
+ INFOHAT: 2,
+ INFOGRON: 3,
+ HEAD: 4,
+ ARMLFT: 5,
+ ARMRT: 6,
+ CLAWLFT: 7,
+ CLAWRT: 8,
+ LEGLFT: 9,
+ LEGRT: 10
+});
+
+// LegoActorParts enum — indices into the 10-part array on each actor
+export const ActorPart = Object.freeze({
+ BODY: 0,
+ INFOHAT: 1,
+ INFOGRON: 2,
+ HEAD: 3,
+ ARMLFT: 4,
+ ARMRT: 5,
+ CLAWLFT: 6,
+ CLAWRT: 7,
+ LEGLFT: 8,
+ LEGRT: 9
+});
+
+// Mapping from ActorPart index to the ActorLOD that provides its transform.
+// ActorLODs[0] ("top") is the root and not directly a part.
+// Parts 0..9 map to ActorLODs[1..10] respectively.
+export const partToLODIndex = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
+
+/**
+ * g_actorLODs[11] — transform/bounding data for each body part position.
+ * Fields: name, parentName, flags, boundingSphere[4], boundingBox[6],
+ * position[3], direction[3], up[3]
+ */
+export const ActorLODs = Object.freeze([
+ { name: 'top', parentName: 'top', flags: 0,
+ boundingSphere: [0.000267, 0.780808, -0.01906, 0.951612],
+ boundingBox: [-0.461166, -0.002794, -0.299442, 0.4617, 1.56441, 0.261321],
+ position: [0, 0, 0], direction: [0, 0, 1], up: [0, 1, 0] },
+ { name: 'body', parentName: 'body', flags: ActorLODFlags.USE_TEXTURE,
+ boundingSphere: [0.00158332, 0.401828, -0.00048697, 0.408071],
+ boundingBox: [-0.287507, 0.150419, -0.147452, 0.289219, 0.649774, 0.14258],
+ position: [-0.00089, 0.436353, 0.007277], direction: [0, 0, 1], up: [0, 1, 0] },
+ { name: 'infohat', parentName: 'infohat', flags: ActorLODFlags.USE_COLOR,
+ boundingSphere: [0, -0.00938, -0.01955, 0.35],
+ boundingBox: [-0.231822, -0.140237, -0.320954, 0.234149, 0.076968, 0.249083],
+ position: [0.000191, 1.519793, 0.001767], direction: [0, 0, 1], up: [0, 1, 0] },
+ { name: 'infogron', parentName: 'infogron', flags: ActorLODFlags.USE_COLOR,
+ boundingSphere: [0, 0.11477, 0.00042, 0.26],
+ boundingBox: [-0.285558, -0.134391, -0.142231, 0.285507, 0.152986, 0.143071],
+ position: [-0.00089, 0.436353, 0.007277], direction: [0, 0, 1], up: [0, 1, 0] },
+ { name: 'head', parentName: 'head', flags: ActorLODFlags.USE_TEXTURE,
+ boundingSphere: [0, -0.03006, 0, 0.3],
+ boundingBox: [-0.189506, -0.209665, -0.189824, 0.189532, 0.228822, 0.194945],
+ position: [-0.00105, 1.293115, 0.001781], direction: [0, 0, 1], up: [0, 1, 0] },
+ { name: 'arm-lft', parentName: 'arm-lft', flags: ActorLODFlags.USE_COLOR,
+ boundingSphere: [-0.06815, -0.0973747, 0.0154655, 0.237],
+ boundingBox: [-0.137931, -0.282775, -0.105316, 0.000989, 0.100221, 0.140759],
+ position: [-0.225678, 0.963312, 0.023286],
+ direction: [-0.003031, -0.017187, 0.999848], up: [0.173622, 0.984658, 0.017453] },
+ { name: 'arm-rt', parentName: 'arm-rt', flags: ActorLODFlags.USE_COLOR,
+ boundingSphere: [0.0680946, -0.097152, 0.0152722, 0.237],
+ boundingBox: [0.00141, -0.289604, -0.100831, 0.138786, 0.09291, 0.145437],
+ position: [0.223494, 0.963583, 0.018302],
+ direction: [0, 0, 1], up: [-0.173648, 0.984808, 0] },
+ { name: 'claw-lft', parentName: 'claw-lft', flags: ActorLODFlags.USE_COLOR,
+ boundingSphere: [0.000773381, -0.101422, -0.0237761, 0.15],
+ boundingBox: [-0.089838, -0.246208, -0.117735, 0.091275, 0.000263, 0.07215],
+ position: [-0.341869, 0.700355, 0.092779],
+ direction: [0.000001, 0.000003, 1], up: [0.190812, 0.981627, -0.000003] },
+ { name: 'claw-rt', parentName: 'claw-lft', flags: ActorLODFlags.USE_COLOR,
+ boundingSphere: [0.000773381, -0.101422, -0.0237761, 0.15],
+ boundingBox: [-0.095016, -0.245349, -0.117979, 0.086528, 0.00067, 0.069743],
+ position: [0.343317, 0.69924, 0.096123],
+ direction: [0.00606, -0.034369, 0.999391], up: [-0.190704, 0.981027, 0.034894] },
+ { name: 'leg-lft', parentName: 'leg', flags: ActorLODFlags.USE_COLOR,
+ boundingSphere: [0.00433584, -0.177404, -0.0313928, 0.33],
+ boundingBox: [-0.129782, -0.440428, -0.184207, 0.13817, 0.118415, 0.122607],
+ position: [-0.156339, 0.436087, 0.006822], direction: [0, 0, 1], up: [0, 1, 0] },
+ { name: 'leg-rt', parentName: 'leg', flags: ActorLODFlags.USE_COLOR,
+ boundingSphere: [0.00433584, -0.177404, -0.0313928, 0.33],
+ boundingBox: [-0.132864, -0.437138, -0.183944, 0.134614, 0.12043, 0.121888],
+ position: [0.151154, 0.436296, 0.007373], direction: [0, 0, 1], up: [0, 1, 0] }
+]);
+
+// Index arrays — 0xff marks end-of-list sentinel
+export const hatPartIndices = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19];
+export const pepperHatPartIndices = [21, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19];
+export const infomanHatPartIndices = [22];
+export const ghostHatPartIndices = [20];
+export const bodyPartIndices = [0, 1, 2, 3, 4, 5, 6, 7];
+export const hatColorIndices = [0, 1, 2, 3, 4, 5, 6, 7];
+export const faceTextureIndices = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13];
+export const chestTextureIndices = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 27];
+export const armColorIndices = [0, 1, 2, 3, 4, 5, 6, 7];
+export const clawRightColorIndices = [0, 1, 2, 3, 4, 5, 6, 7];
+export const clawLeftColorIndices = [0, 1, 2, 3, 4, 5, 6, 7];
+export const gronColorIndices = [0, 1, 2, 3, 4, 5, 6, 7];
+export const legColorIndices = [0, 1, 2, 3, 4, 5, 6, 7];
+
+// Name arrays
+export const hatPartNames = [
+ 'baseball', 'chef', 'cap', 'cophat', 'helmet', 'ponytail', 'pageboy', 'shrthair',
+ 'bald', 'flower', 'cboyhat', 'cuphat', 'cathat', 'backbcap', 'pizhat', 'caprc',
+ 'capch', 'capdb', 'capjs', 'capmd', 'sheet', 'phat', 'icap'
+];
+
+export const bodyPartNames = [
+ 'body', 'bodyred', 'bodyblck', 'bodywhte', 'bodyyllw', 'bodyblue', 'bodygren', 'bodybrwn'
+];
+
+export const chestTextures = [
+ 'peprchst.gif', 'mamachst.gif', 'papachst.gif', 'nickchst.gif', 'norachst.gif',
+ 'infochst.gif', 'shftchst.gif', 'rac1chst.gif', 'rac2chst.gif', 'bth1chst.gif',
+ 'bth2chst.gif', 'mech.gif', 'polkadot.gif', 'bowtie.gif', 'postchst.gif',
+ 'vest.gif', 'doctor.gif', 'copchest.gif', 'l.gif', 'e.gif',
+ 'g.gif', 'o.gif', 'fruit.gif', 'flowers.gif', 'construct.gif',
+ 'paint.gif', 'l6.gif', 'unkchst.gif'
+];
+
+export const faceTextures = [
+ 'peprface.gif', 'mamaface.gif', 'papaface.gif', 'nickface.gif', 'noraface.gif',
+ 'infoface.gif', 'shftface.gif', 'dogface.gif', 'womanshd.gif', 'smileshd.gif',
+ 'woman.gif', 'smile.gif', 'mustache.gif', 'black.gif'
+];
+
+export const colorAliases = [
+ 'lego white', 'lego black', 'lego yellow', 'lego red', 'lego blue', 'lego brown', 'lego lt grey', 'lego green'
+];
+
+// Character type classification
+export const CharacterType = Object.freeze({
+ STANDARD: 0,
+ PEPPER: 1,
+ INFOMAN: 2,
+ GHOST: 3
+});
+
+/**
+ * Determine character type from actor index
+ */
+export function getCharacterType(actorIndex) {
+ if (actorIndex === 0 || actorIndex === 56) return CharacterType.PEPPER; // pepper, pep
+ if (actorIndex === 5) return CharacterType.INFOMAN;
+ if (actorIndex >= 48 && actorIndex <= 53) return CharacterType.GHOST; // ghost, ghost01..05
+ return CharacterType.STANDARD;
+}
+
+// Reference names for the index arrays (used to build part configs)
+const HP = 'hatPartIndices';
+const PHP = 'pepperHatPartIndices';
+const IHP = 'infomanHatPartIndices';
+const GHP = 'ghostHatPartIndices';
+const BP = 'bodyPartIndices';
+const HC = 'hatColorIndices';
+const FT = 'faceTextureIndices';
+const CT = 'chestTextureIndices';
+const AC = 'armColorIndices';
+const CRC = 'clawRightColorIndices';
+const CLC = 'clawLeftColorIndices';
+const GC = 'gronColorIndices';
+const LC = 'legColorIndices';
+
+// Lookup tables for the index array references
+const indexArrays = {
+ [HP]: hatPartIndices,
+ [PHP]: pepperHatPartIndices,
+ [IHP]: infomanHatPartIndices,
+ [GHP]: ghostHatPartIndices,
+ [BP]: bodyPartIndices,
+ [HC]: hatColorIndices,
+ [FT]: faceTextureIndices,
+ [CT]: chestTextureIndices,
+ [AC]: armColorIndices,
+ [CRC]: clawRightColorIndices,
+ [CLC]: clawLeftColorIndices,
+ [GC]: gronColorIndices,
+ [LC]: legColorIndices
+};
+
+const nameArrays = {
+ hatPartNames,
+ bodyPartNames,
+ chestTextures,
+ faceTextures,
+ colorAliases
+};
+
+/**
+ * Helper to build a Part entry from compact form.
+ * C++ Part has: { partNameIndices, partName, partNameIndex, nameIndices, names, nameIndex }
+ * We store references to our JS arrays.
+ */
+function P(partNameIndicesRef, partNamesRef, partNameIndex, nameIndicesRef, namesRef, nameIndex) {
+ return {
+ partNameIndices: partNameIndicesRef ? indexArrays[partNameIndicesRef] : null,
+ partNames: partNamesRef ? nameArrays[partNamesRef] : null,
+ partNameIndex,
+ nameIndices: indexArrays[nameIndicesRef],
+ names: nameArrays[namesRef],
+ nameIndex
+ };
+}
+
+// Short aliases for name array refs
+const HPN = 'hatPartNames';
+const BPN = 'bodyPartNames';
+const CTN = 'chestTextures';
+const FTN = 'faceTextures';
+const CA = 'colorAliases';
+
+/**
+ * g_actorInfoInit[66] — All 66 game actors.
+ * Each entry: { name, sound, move, mood, parts[10] }
+ * Parts order: body, infohat, infogron, head, armlft, armrt, clawlft, clawrt, leglft, legrt
+ */
+export const ActorInfoInit = Object.freeze([
+ /* 0 */ { name: 'pepper', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 1, CT, CTN, 0), P(PHP, HPN, 0, HC, CA, 0),
+ P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 0),
+ P(null, null, 0, AC, CA, 3), P(null, null, 0, AC, CA, 3),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] },
+ /* 1 */ { name: 'mama', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 1, CT, CTN, 1), P(HP, HPN, 1, HC, CA, 0),
+ P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 1),
+ P(null, null, 0, AC, CA, 3), P(null, null, 0, AC, CA, 3),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] },
+ /* 2 */ { name: 'papa', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 2, CT, CTN, 2), P(HP, HPN, 1, HC, CA, 0),
+ P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 2),
+ P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] },
+ /* 3 */ { name: 'nick', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 2, CT, CTN, 3), P(HP, HPN, 3, HC, CA, 1),
+ P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 3),
+ P(null, null, 0, AC, CA, 1), P(null, null, 0, AC, CA, 1),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] },
+ /* 4 */ { name: 'laura', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 2, CT, CTN, 4), P(HP, HPN, 3, HC, CA, 1),
+ P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 4),
+ P(null, null, 0, AC, CA, 1), P(null, null, 0, AC, CA, 1),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] },
+ /* 5 */ { name: 'infoman', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 1, CT, CTN, 5), P(IHP, HPN, 0, HC, CA, 3),
+ P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 5),
+ P(null, null, 0, AC, CA, 1), P(null, null, 0, AC, CA, 1),
+ P(null, null, 0, CLC, CA, 0), P(null, null, 0, CRC, CA, 0),
+ P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] },
+ /* 6 */ { name: 'brickstr', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 3, CT, CTN, 6), P(HP, HPN, 13, HC, CA, 1),
+ P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 6),
+ P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0),
+ P(null, null, 0, CLC, CA, 0), P(null, null, 0, CRC, CA, 4),
+ P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] },
+ /* 7 */ { name: 'studs', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 1, CT, CTN, 7), P(HP, HPN, 4, HC, CA, 1),
+ P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 7),
+ P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] },
+ /* 8 */ { name: 'rhoda', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 5, CT, CTN, 8), P(HP, HPN, 4, HC, CA, 3),
+ P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 8),
+ P(null, null, 0, AC, CA, 3), P(null, null, 0, AC, CA, 3),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] },
+ /* 9 */ { name: 'valerie', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 1, CT, CTN, 9), P(HP, HPN, 5, HC, CA, 3),
+ P(null, null, 0, GC, CA, 3), P(null, null, 0, FT, FTN, 8),
+ P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 2), P(null, null, 0, LC, CA, 2)] },
+ /* 10 */ { name: 'snap', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 3, CT, CTN, 10), P(HP, HPN, 0, HC, CA, 4),
+ P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 9),
+ P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 2), P(null, null, 0, LC, CA, 2)] },
+ /* 11 */ { name: 'pt', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 5, CT, CTN, 11), P(HP, HPN, 6, HC, CA, 1),
+ P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 8),
+ P(null, null, 0, AC, CA, 4), P(null, null, 0, AC, CA, 4),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] },
+ /* 12 */ { name: 'mg', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 1, CT, CTN, 12), P(HP, HPN, 6, HC, CA, 5),
+ P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 10),
+ P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] },
+ /* 13 */ { name: 'bu', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 3, CT, CTN, 13), P(HP, HPN, 7, HC, CA, 5),
+ P(null, null, 0, GC, CA, 5), P(null, null, 0, FT, FTN, 11),
+ P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 5), P(null, null, 0, LC, CA, 5)] },
+ /* 14 */ { name: 'ml', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 5, CT, CTN, 14), P(HP, HPN, 2, HC, CA, 4),
+ P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 12),
+ P(null, null, 0, AC, CA, 4), P(null, null, 0, AC, CA, 4),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] },
+ /* 15 */ { name: 'nu', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 5, CT, CTN, 11), P(HP, HPN, 7, HC, CA, 1),
+ P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 7),
+ P(null, null, 0, AC, CA, 4), P(null, null, 0, AC, CA, 4),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] },
+ /* 16 */ { name: 'na', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 3, CT, CTN, 15), P(HP, HPN, 10, HC, CA, 3),
+ P(null, null, 0, GC, CA, 3), P(null, null, 0, FT, FTN, 8),
+ P(null, null, 0, AC, CA, 4), P(null, null, 0, AC, CA, 4),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 3), P(null, null, 0, LC, CA, 3)] },
+ /* 17 */ { name: 'cl', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 3, CT, CTN, 16), P(HP, HPN, 19, HC, CA, 0),
+ P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 12),
+ P(null, null, 0, AC, CA, 4), P(null, null, 0, AC, CA, 4),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] },
+ /* 18 */ { name: 'en', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 3, CT, CTN, 16), P(HP, HPN, 0, HC, CA, 0),
+ P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 11),
+ P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] },
+ /* 19 */ { name: 're', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 3, CT, CTN, 16), P(HP, HPN, 0, HC, CA, 0),
+ P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 11),
+ P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] },
+ /* 20 */ { name: 'ro', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 2, CT, CTN, 17), P(HP, HPN, 3, HC, CA, 1),
+ P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 9),
+ P(null, null, 0, AC, CA, 1), P(null, null, 0, AC, CA, 1),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] },
+ /* 21 */ { name: 'd1', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 5, CT, CTN, 11), P(HP, HPN, 15, HC, CA, 0),
+ P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 11),
+ P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] },
+ /* 22 */ { name: 'd2', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 5, CT, CTN, 11), P(HP, HPN, 16, HC, CA, 0),
+ P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 11),
+ P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] },
+ /* 23 */ { name: 'd3', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 5, CT, CTN, 11), P(HP, HPN, 17, HC, CA, 0),
+ P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 11),
+ P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] },
+ /* 24 */ { name: 'd4', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 5, CT, CTN, 11), P(HP, HPN, 18, HC, CA, 0),
+ P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 11),
+ P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] },
+ /* 25 */ { name: 'l1', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 1, CT, CTN, 18), P(HP, HPN, 5, HC, CA, 1),
+ P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 11),
+ P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] },
+ /* 26 */ { name: 'l2', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 1, CT, CTN, 19), P(HP, HPN, 6, HC, CA, 1),
+ P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 12),
+ P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] },
+ /* 27 */ { name: 'l3', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 1, CT, CTN, 20), P(HP, HPN, 7, HC, CA, 1),
+ P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 11),
+ P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] },
+ /* 28 */ { name: 'l4', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 1, CT, CTN, 21), P(HP, HPN, 7, HC, CA, 1),
+ P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 11),
+ P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] },
+ /* 29 */ { name: 'l5', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 1, CT, CTN, 26), P(HP, HPN, 7, HC, CA, 1),
+ P(null, null, 0, GC, CA, 3), P(null, null, 0, FT, FTN, 12),
+ P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] },
+ /* 30 */ { name: 'l6', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 1, CT, CTN, 26), P(HP, HPN, 0, HC, CA, 1),
+ P(null, null, 0, GC, CA, 3), P(null, null, 0, FT, FTN, 11),
+ P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] },
+ /* 31 */ { name: 'b1', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 0, LC, CA, 1), P(HP, HPN, 7, HC, CA, 1),
+ P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 12),
+ P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] },
+ /* 32 */ { name: 'b2', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 0, LC, CA, 1), P(HP, HPN, 5, HC, CA, 1),
+ P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 10),
+ P(null, null, 0, AC, CA, 3), P(null, null, 0, AC, CA, 3),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] },
+ /* 33 */ { name: 'b3', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 0, LC, CA, 4), P(HP, HPN, 7, HC, CA, 5),
+ P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 11),
+ P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] },
+ /* 34 */ { name: 'b4', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 0, LC, CA, 1), P(HP, HPN, 7, HC, CA, 1),
+ P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 9),
+ P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] },
+ /* 35 */ { name: 'cm', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 4, CT, CTN, 22), P(HP, HPN, 9, HC, CA, 2),
+ P(null, null, 0, GC, CA, 3), P(null, null, 0, FT, FTN, 8),
+ P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 3), P(null, null, 0, LC, CA, 3)] },
+ /* 36 */ { name: 'gd', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 0, LC, CA, 1), P(HP, HPN, 7, HC, CA, 1),
+ P(null, null, 0, GC, CA, 6), P(null, null, 0, FT, FTN, 11),
+ P(null, null, 0, AC, CA, 6), P(null, null, 0, AC, CA, 6),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 6), P(null, null, 0, LC, CA, 6)] },
+ /* 37 */ { name: 'rd', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 0, LC, CA, 3), P(HP, HPN, 0, HC, CA, 3),
+ P(null, null, 0, GC, CA, 7), P(null, null, 0, FT, FTN, 9),
+ P(null, null, 0, AC, CA, 3), P(null, null, 0, AC, CA, 3),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 7), P(null, null, 0, LC, CA, 7)] },
+ /* 38 */ { name: 'pg', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 0, LC, CA, 3), P(HP, HPN, 5, HC, CA, 3),
+ P(null, null, 0, GC, CA, 3), P(null, null, 0, FT, FTN, 11),
+ P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 3), P(null, null, 0, LC, CA, 3)] },
+ /* 39 */ { name: 'bd', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 0, LC, CA, 6), P(HP, HPN, 0, HC, CA, 6),
+ P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 12),
+ P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] },
+ /* 40 */ { name: 'sy', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 0, LC, CA, 4), P(HP, HPN, 5, HC, CA, 6),
+ P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 10),
+ P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] },
+ /* 41 */ { name: 'gn', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 6, CT, CTN, 13), P(HP, HPN, 7, HC, CA, 5),
+ P(null, null, 0, GC, CA, 5), P(null, null, 0, FT, FTN, 9),
+ P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 5), P(null, null, 0, LC, CA, 5)] },
+ /* 42 */ { name: 'df', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 5, CT, CTN, 23), P(HP, HPN, 6, HC, CA, 5),
+ P(null, null, 0, GC, CA, 6), P(null, null, 0, FT, FTN, 8),
+ P(null, null, 0, AC, CA, 4), P(null, null, 0, AC, CA, 4),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 6), P(null, null, 0, LC, CA, 6)] },
+ /* 43 */ { name: 'bs', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 3, CT, CTN, 10), P(HP, HPN, 7, HC, CA, 3),
+ P(null, null, 0, GC, CA, 7), P(null, null, 0, FT, FTN, 11),
+ P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 2), P(null, null, 0, LC, CA, 2)] },
+ /* 44 */ { name: 'lt', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 3, CT, CTN, 10), P(HP, HPN, 7, HC, CA, 1),
+ P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 11),
+ P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 2), P(null, null, 0, LC, CA, 2)] },
+ /* 45 */ { name: 'st', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 1, CT, CTN, 9), P(HP, HPN, 5, HC, CA, 5),
+ P(null, null, 0, GC, CA, 3), P(null, null, 0, FT, FTN, 10),
+ P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 2), P(null, null, 0, LC, CA, 2)] },
+ /* 46 */ { name: 'bm', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 3, CT, CTN, 24), P(HP, HPN, 0, HC, CA, 4),
+ P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 7),
+ P(null, null, 0, AC, CA, 6), P(null, null, 0, AC, CA, 6),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] },
+ /* 47 */ { name: 'jk', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 3, CT, CTN, 24), P(HP, HPN, 0, HC, CA, 4),
+ P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 9),
+ P(null, null, 0, AC, CA, 6), P(null, null, 0, AC, CA, 6),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] },
+ /* 48 */ { name: 'ghost', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 0, LC, CA, 0), P(GHP, HPN, 0, HC, CA, 0),
+ P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 13),
+ P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0),
+ P(null, null, 0, CLC, CA, 0), P(null, null, 0, CRC, CA, 0),
+ P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] },
+ /* 49 */ { name: 'ghost01', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 0, LC, CA, 0), P(GHP, HPN, 0, HC, CA, 0),
+ P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 13),
+ P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0),
+ P(null, null, 0, CLC, CA, 0), P(null, null, 0, CRC, CA, 0),
+ P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] },
+ /* 50 */ { name: 'ghost02', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 0, LC, CA, 0), P(GHP, HPN, 0, HC, CA, 0),
+ P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 13),
+ P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0),
+ P(null, null, 0, CLC, CA, 0), P(null, null, 0, CRC, CA, 0),
+ P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] },
+ /* 51 */ { name: 'ghost03', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 0, LC, CA, 0), P(GHP, HPN, 0, HC, CA, 0),
+ P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 13),
+ P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0),
+ P(null, null, 0, CLC, CA, 0), P(null, null, 0, CRC, CA, 0),
+ P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] },
+ /* 52 */ { name: 'ghost04', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 0, LC, CA, 0), P(GHP, HPN, 0, HC, CA, 0),
+ P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 13),
+ P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0),
+ P(null, null, 0, CLC, CA, 0), P(null, null, 0, CRC, CA, 0),
+ P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] },
+ /* 53 */ { name: 'ghost05', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 0, LC, CA, 0), P(GHP, HPN, 0, HC, CA, 0),
+ P(null, null, 0, GC, CA, 0), P(null, null, 0, FT, FTN, 13),
+ P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0),
+ P(null, null, 0, CLC, CA, 0), P(null, null, 0, CRC, CA, 0),
+ P(null, null, 0, LC, CA, 0), P(null, null, 0, LC, CA, 0)] },
+ /* 54 */ { name: 'hg', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 0, LC, CA, 3), P(HP, HPN, 8, HC, CA, 3),
+ P(null, null, 0, GC, CA, 3), P(null, null, 0, FT, FTN, 8),
+ P(null, null, 0, AC, CA, 3), P(null, null, 0, AC, CA, 3),
+ P(null, null, 0, CLC, CA, 3), P(null, null, 0, CRC, CA, 3),
+ P(null, null, 0, LC, CA, 3), P(null, null, 0, LC, CA, 3)] },
+ /* 55 */ { name: 'pntgy', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 0, LC, CA, 3), P(HP, HPN, 0, HC, CA, 3),
+ P(null, null, 0, GC, CA, 3), P(null, null, 0, FT, FTN, 7),
+ P(null, null, 0, AC, CA, 3), P(null, null, 0, AC, CA, 3),
+ P(null, null, 0, CLC, CA, 3), P(null, null, 0, CRC, CA, 3),
+ P(null, null, 0, LC, CA, 3), P(null, null, 0, LC, CA, 3)] },
+ /* 56 */ { name: 'pep', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 1, CT, CTN, 0), P(PHP, HPN, 0, HC, CA, 0),
+ P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 0),
+ P(null, null, 0, AC, CA, 3), P(null, null, 0, AC, CA, 3),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] },
+ /* 57 */ { name: 'cop01', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 2, CT, CTN, 17), P(HP, HPN, 3, HC, CA, 1),
+ P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 9),
+ P(null, null, 0, AC, CA, 1), P(null, null, 0, AC, CA, 1),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] },
+ /* 58 */ { name: 'actor_01', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 0, LC, CA, 4), P(HP, HPN, 5, HC, CA, 6),
+ P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 10),
+ P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] },
+ /* 59 */ { name: 'actor_02', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 0, LC, CA, 6), P(HP, HPN, 0, HC, CA, 6),
+ P(null, null, 0, GC, CA, 1), P(null, null, 0, FT, FTN, 12),
+ P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 1), P(null, null, 0, LC, CA, 1)] },
+ /* 60 */ { name: 'actor_03', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 0, LC, CA, 1), P(HP, HPN, 7, HC, CA, 1),
+ P(null, null, 0, GC, CA, 6), P(null, null, 0, FT, FTN, 11),
+ P(null, null, 0, AC, CA, 6), P(null, null, 0, AC, CA, 6),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 6), P(null, null, 0, LC, CA, 6)] },
+ /* 61 */ { name: 'actor_04', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 1, CT, CTN, 12), P(HP, HPN, 6, HC, CA, 5),
+ P(null, null, 0, GC, CA, 4), P(null, null, 0, FT, FTN, 10),
+ P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 4), P(null, null, 0, LC, CA, 4)] },
+ /* 62 */ { name: 'actor_05', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 4, CT, CTN, 22), P(HP, HPN, 9, HC, CA, 2),
+ P(null, null, 0, GC, CA, 3), P(null, null, 0, FT, FTN, 8),
+ P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 3), P(null, null, 0, LC, CA, 3)] },
+ /* 63 */ { name: 'btmncycl', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 0, LC, CA, 3), P(HP, HPN, 5, HC, CA, 3),
+ P(null, null, 0, GC, CA, 3), P(null, null, 0, FT, FTN, 11),
+ P(null, null, 0, AC, CA, 0), P(null, null, 0, AC, CA, 0),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 3), P(null, null, 0, LC, CA, 3)] },
+ /* 64 */ { name: 'cboycycl', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 3, CT, CTN, 10), P(HP, HPN, 7, HC, CA, 3),
+ P(null, null, 0, GC, CA, 7), P(null, null, 0, FT, FTN, 11),
+ P(null, null, 0, AC, CA, 2), P(null, null, 0, AC, CA, 2),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 2), P(null, null, 0, LC, CA, 2)] },
+ /* 65 */ { name: 'boatman', sound: 0, move: 0, mood: 0, parts: [
+ P(BP, BPN, 0, LC, CA, 3), P(HP, HPN, 0, HC, CA, 3),
+ P(null, null, 0, GC, CA, 7), P(null, null, 0, FT, FTN, 9),
+ P(null, null, 0, AC, CA, 3), P(null, null, 0, AC, CA, 3),
+ P(null, null, 0, CLC, CA, 2), P(null, null, 0, CRC, CA, 2),
+ P(null, null, 0, LC, CA, 7), P(null, null, 0, LC, CA, 7)] }
+]);
+
+/**
+ * Save file field offsets within the 16-byte character record.
+ * The save file stores these per-character values that override defaults:
+ * sound(S32) + move(S32) + mood(U8) + hatPartNameIndex(U8) + hatNameIndex(U8)
+ * + infogronNameIndex(U8) + armlftNameIndex(U8) + armrtNameIndex(U8)
+ * + leglftNameIndex(U8) + legrtNameIndex(U8)
+ */
+export const CharacterFieldOffsets = Object.freeze({
+ sound: 0, // S32
+ move: 4, // S32
+ mood: 8, // U8
+ hatPartNameIndex: 9, // U8
+ hatNameIndex: 10, // U8
+ infogronNameIndex: 11, // U8
+ armlftNameIndex: 12, // U8
+ armrtNameIndex: 13, // U8
+ leglftNameIndex: 14, // U8
+ legrtNameIndex: 15 // U8
+});
+
+export const CHARACTER_RECORD_SIZE = 16;
+
+/**
+ * Part labels for display
+ */
+export const ActorPartLabels = Object.freeze({
+ 0: 'Body',
+ 1: 'Hat',
+ 2: 'Torso Emblem',
+ 3: 'Head',
+ 4: 'Left Arm',
+ 5: 'Right Arm',
+ 6: 'Left Claw',
+ 7: 'Right Claw',
+ 8: 'Left Leg',
+ 9: 'Right Leg'
+});
diff --git a/src/core/savegame/constants.js b/src/core/savegame/constants.js
index eb6c8cf..7d82085 100644
--- a/src/core/savegame/constants.js
+++ b/src/core/savegame/constants.js
@@ -2,6 +2,12 @@
* Constants and enums from KSY save file specifications
*/
+// Re-export actor constants
+export { ActorInfoInit, ActorLODs, ActorPart, ActorLODFlags, ActorPartLabels,
+ CharacterType, getCharacterType, CharacterFieldOffsets, CHARACTER_RECORD_SIZE,
+ hatPartNames, bodyPartNames, chestTextures, faceTextures, colorAliases
+} from './actorConstants.js';
+
// Save game file version (must match for valid saves)
export const SAVEGAME_VERSION = 0x1000c; // 65548
diff --git a/src/core/savegame/index.js b/src/core/savegame/index.js
index 14e2e7c..9619ac3 100644
--- a/src/core/savegame/index.js
+++ b/src/core/savegame/index.js
@@ -91,6 +91,8 @@ export async function listSaveSlots() {
missions: null,
variables: null,
act1State: null,
+ characters: null,
+ charactersOffset: null,
playerName: null,
buffer: null
};
@@ -104,6 +106,8 @@ export async function listSaveSlots() {
slot.missions = parsed.missions;
slot.variables = parsed.variables;
slot.act1State = parsed.act1State || null;
+ slot.characters = parsed.characters || null;
+ slot.charactersOffset = parsed.charactersOffset || null;
slot.buffer = buffer;
// Try to get player name
@@ -168,6 +172,8 @@ export async function loadSaveSlot(slotNumber) {
missions: parsed.missions,
variables: parsed.variables,
act1State: parsed.act1State || null,
+ characters: parsed.characters || null,
+ charactersOffset: parsed.charactersOffset || null,
playerName,
buffer
};
@@ -238,6 +244,17 @@ export async function updateSaveSlot(slotNumber, updates) {
}
}
+ // Apply character update
+ if (updates.character) {
+ const { characterIndex, field, value } = updates.character;
+ const charSerializer = createSerializer(newBuffer);
+ const result = charSerializer.updateCharacter(characterIndex, field, value);
+ if (result) {
+ newBuffer = result;
+ modified = true;
+ }
+ }
+
// Apply texture update
if (updates.texture) {
const { textureName, textureData } = updates.texture;
diff --git a/src/lib/SaveEditorPage.svelte b/src/lib/SaveEditorPage.svelte
index 8970c86..2d6b206 100644
--- a/src/lib/SaveEditorPage.svelte
+++ b/src/lib/SaveEditorPage.svelte
@@ -6,6 +6,7 @@
import SkyColorEditor from './save-editor/SkyColorEditor.svelte';
import LightPositionEditor from './save-editor/LightPositionEditor.svelte';
import VehicleEditor from './save-editor/VehicleEditor.svelte';
+ import ActorEditor from './save-editor/ActorEditor.svelte';
import { saveEditorState, currentPage } from '../stores.js';
import { listSaveSlots, updateSaveSlot, updatePlayerName } from '../core/savegame/index.js';
import { Actor, ActorNames } from '../core/savegame/constants.js';
@@ -23,7 +24,8 @@
{ id: 'player', label: 'Player', firstSection: 'name' },
{ id: 'scores', label: 'Scores', firstSection: null },
{ id: 'island', label: 'Island', firstSection: 'skycolor' },
- { id: 'vehicles', label: 'Vehicles', firstSection: null }
+ { id: 'vehicles', label: 'Vehicles', firstSection: null },
+ { id: 'actors', label: 'Actors', firstSection: null }
];
// Reset state when navigating to this page
@@ -120,7 +122,7 @@
if (updated) {
slots = slots.map(s =>
s.slotNumber === selectedSlot
- ? { ...s, variables: updated.variables, act1State: updated.act1State }
+ ? { ...s, variables: updated.variables, act1State: updated.act1State, characters: updated.characters }
: s
);
}
@@ -405,6 +407,13 @@