# Haxe Serializer Generator Implementation Plan ## Overview This document outlines the complete implementation plan for adding Haxe support to the Spine runtime testing infrastructure. The goal is to generate a Haxe serializer that produces identical JSON output to the existing Java and C++ serializers, enabling cross-runtime compatibility testing. ## Current System Architecture The existing system consists of three layers: 1. **SerializerIR Generation** (`tests/src/generate-serializer-ir.ts`) - Analyzes Java API to create intermediate representation - Outputs `tests/output/serializer-ir.json` with type and property metadata 2. **Language-Specific Generators** - `tests/src/generate-java-serializer.ts` - Java implementation - `tests/src/generate-cpp-serializer.ts` - C++ implementation - **Missing**: `tests/src/generate-haxe-serializer.ts` 3. **HeadlessTest Applications** - `spine-libgdx/spine-libgdx-tests/src/com/esotericsoftware/spine/HeadlessTest.java` - `spine-cpp/tests/HeadlessTest.cpp` - **Missing**: `spine-haxe/tests/HeadlessTest.hx` 4. **Test Runner** (`tests/src/headless-test-runner.ts`) - Orchestrates building and running tests - Compares outputs for consistency - Currently supports: Java, C++ - **Needs**: Haxe support ## SerializerIR Structure Reference Based on `tests/src/generate-serializer-ir.ts:10-80`: ```typescript interface SerializerIR { publicMethods: PublicMethod[]; // Entry point methods writeMethods: WriteMethod[]; // Type-specific serializers enumMappings: { [enumName: string]: { [javaValue: string]: string } }; } interface WriteMethod { name: string; // writeSkeletonData, writeBone, etc. paramType: string; // Full Java class name properties: Property[]; // Fields to serialize isAbstractType: boolean; // Needs instanceof chain subtypeChecks?: SubtypeCheck[]; // For abstract types } type Property = Primitive | Object | Enum | Array | NestedArray; ``` ## Implementation Plan ### 1. Generate Haxe Serializer (`tests/src/generate-haxe-serializer.ts`) Create the generator following the pattern from existing generators: ```typescript #!/usr/bin/env tsx import * as fs from 'fs'; import * as path from 'path'; import { fileURLToPath } from 'url'; import type { Property, SerializerIR } from './generate-serializer-ir'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); function transformType(javaType: string): string { // Java → Haxe type mappings const primitiveMap: Record = { 'String': 'String', 'int': 'Int', 'float': 'Float', 'boolean': 'Bool', 'short': 'Int', 'byte': 'Int', 'double': 'Float', 'long': 'Int' }; // Remove package prefixes and map primitives const simpleName = javaType.includes('.') ? javaType.split('.').pop()! : javaType; if (primitiveMap[simpleName]) { return primitiveMap[simpleName]; } // Handle arrays: Java T[] → Haxe Array if (simpleName.endsWith('[]')) { const baseType = simpleName.slice(0, -2); return `Array<${transformType(baseType)}>`; } // Java Array stays Array in Haxe if (simpleName.startsWith('Array<')) { return simpleName; } // Object types: keep class name, remove package return simpleName; } function mapJavaGetterToHaxeField(javaGetter: string, objName: string): string { // Map Java getter methods to Haxe field access // Based on analysis of existing Haxe classes in spine-haxe/spine-haxe/spine/ if (javaGetter.endsWith('()')) { const methodName = javaGetter.slice(0, -2); // Remove get/is prefix and convert to camelCase field if (methodName.startsWith('get')) { const fieldName = methodName.slice(3); const haxeField = fieldName.charAt(0).toLowerCase() + fieldName.slice(1); return `${objName}.${haxeField}`; } if (methodName.startsWith('is')) { const fieldName = methodName.slice(2); const haxeField = fieldName.charAt(0).toLowerCase() + fieldName.slice(1); return `${objName}.${haxeField}`; } // Some methods might be direct field names return `${objName}.${methodName}`; } // Direct field access (already in correct format) return `${objName}.${javaGetter}`; } function generatePropertyCode(property: Property, indent: string, enumMappings: { [enumName: string]: { [javaValue: string]: string } }): string[] { const lines: string[] = []; const accessor = mapJavaGetterToHaxeField(property.getter, 'obj'); switch (property.kind) { case "primitive": lines.push(`${indent}json.writeValue(${accessor});`); break; case "object": if (property.isNullable) { lines.push(`${indent}if (${accessor} == null) {`); lines.push(`${indent} json.writeNull();`); lines.push(`${indent}} else {`); lines.push(`${indent} ${property.writeMethodCall}(${accessor});`); lines.push(`${indent}}`); } else { lines.push(`${indent}${property.writeMethodCall}(${accessor});`); } break; case "enum": { const enumName = property.enumName; const enumMap = enumMappings[enumName]; if (property.isNullable) { lines.push(`${indent}if (${accessor} == null) {`); lines.push(`${indent} json.writeNull();`); lines.push(`${indent}} else {`); } if (enumMap && Object.keys(enumMap).length > 0) { // Generate switch statement for enum mapping lines.push(`${indent}${property.isNullable ? ' ' : ''}switch (${accessor}) {`); for (const [javaValue, haxeValue] of Object.entries(enumMap)) { lines.push(`${indent}${property.isNullable ? ' ' : ''} case ${haxeValue}: json.writeValue("${javaValue}");`); } lines.push(`${indent}${property.isNullable ? ' ' : ''} default: json.writeValue("unknown");`); lines.push(`${indent}${property.isNullable ? ' ' : ''}}`); } else { // Fallback using Type.enumConstructor or similar lines.push(`${indent}${property.isNullable ? ' ' : ''}json.writeValue(Type.enumConstructor(${accessor}));`); } if (property.isNullable) { lines.push(`${indent}}`); } break; } case "array": { if (property.isNullable) { lines.push(`${indent}if (${accessor} == null) {`); lines.push(`${indent} json.writeNull();`); lines.push(`${indent}} else {`); lines.push(`${indent} json.writeArrayStart();`); lines.push(`${indent} for (item in ${accessor}) {`); } else { lines.push(`${indent}json.writeArrayStart();`); lines.push(`${indent}for (item in ${accessor}) {`); } const itemIndent = property.isNullable ? `${indent} ` : `${indent} `; if (property.elementKind === "primitive") { lines.push(`${itemIndent}json.writeValue(item);`); } else { lines.push(`${itemIndent}${property.writeMethodCall}(item);`); } if (property.isNullable) { lines.push(`${indent} }`); lines.push(`${indent} json.writeArrayEnd();`); lines.push(`${indent}}`); } else { lines.push(`${indent}}`); lines.push(`${indent}json.writeArrayEnd();`); } break; } case "nestedArray": { if (property.isNullable) { lines.push(`${indent}if (${accessor} == null) {`); lines.push(`${indent} json.writeNull();`); lines.push(`${indent}} else {`); } const outerIndent = property.isNullable ? `${indent} ` : indent; lines.push(`${outerIndent}json.writeArrayStart();`); lines.push(`${outerIndent}for (nestedArray in ${accessor}) {`); lines.push(`${outerIndent} if (nestedArray == null) {`); lines.push(`${outerIndent} json.writeNull();`); lines.push(`${outerIndent} } else {`); lines.push(`${outerIndent} json.writeArrayStart();`); lines.push(`${outerIndent} for (elem in nestedArray) {`); lines.push(`${outerIndent} json.writeValue(elem);`); lines.push(`${outerIndent} }`); lines.push(`${outerIndent} json.writeArrayEnd();`); lines.push(`${outerIndent} }`); lines.push(`${outerIndent}}`); lines.push(`${outerIndent}json.writeArrayEnd();`); if (property.isNullable) { lines.push(`${indent}}`); } break; } } return lines; } function generateHaxeFromIR(ir: SerializerIR): string { const haxeOutput: string[] = []; // Generate Haxe file header haxeOutput.push('package spine.utils;'); haxeOutput.push(''); haxeOutput.push('import haxe.ds.StringMap;'); haxeOutput.push('import spine.*;'); haxeOutput.push('import spine.animation.*;'); haxeOutput.push('import spine.attachments.*;'); haxeOutput.push(''); haxeOutput.push('class SkeletonSerializer {'); haxeOutput.push(' private var visitedObjects:StringMap = new StringMap();'); haxeOutput.push(' private var nextId:Int = 1;'); haxeOutput.push(' private var json:JsonWriter;'); haxeOutput.push(''); haxeOutput.push(' public function new() {}'); haxeOutput.push(''); // Generate public methods for (const method of ir.publicMethods) { const haxeParamType = transformType(method.paramType); haxeOutput.push(` public function ${method.name}(${method.paramName}:${haxeParamType}):String {`); haxeOutput.push(' visitedObjects = new StringMap();'); haxeOutput.push(' nextId = 1;'); haxeOutput.push(' json = new JsonWriter();'); haxeOutput.push(` ${method.writeMethodCall}(${method.paramName});`); haxeOutput.push(' return json.getString();'); haxeOutput.push(' }'); haxeOutput.push(''); } // Generate write methods for (const method of ir.writeMethods) { const shortName = method.paramType.split('.').pop(); const haxeType = transformType(method.paramType); haxeOutput.push(` private function ${method.name}(obj:${haxeType}):Void {`); if (method.isAbstractType) { // Handle abstract types with Std.isOfType chain (Haxe equivalent of instanceof) if (method.subtypeChecks && method.subtypeChecks.length > 0) { let first = true; for (const subtype of method.subtypeChecks) { const subtypeHaxeName = transformType(subtype.typeName); if (first) { haxeOutput.push(` if (Std.isOfType(obj, ${subtypeHaxeName})) {`); first = false; } else { haxeOutput.push(` } else if (Std.isOfType(obj, ${subtypeHaxeName})) {`); } haxeOutput.push(` ${subtype.writeMethodCall}(cast(obj, ${subtypeHaxeName}));`); } haxeOutput.push(' } else {'); haxeOutput.push(` throw new spine.SpineException("Unknown ${shortName} type");`); haxeOutput.push(' }'); } else { haxeOutput.push(' json.writeNull(); // No concrete implementations after filtering exclusions'); } } else { // Handle concrete types - add cycle detection haxeOutput.push(' if (visitedObjects.exists(obj)) {'); haxeOutput.push(' json.writeValue(visitedObjects.get(obj));'); haxeOutput.push(' return;'); haxeOutput.push(' }'); // Generate reference string const nameGetter = method.properties.find(p => (p.kind === 'object' || p.kind === "primitive") && p.getter === 'getName()' && p.valueType === 'String' ); if (nameGetter) { const nameAccessor = mapJavaGetterToHaxeField('getName()', 'obj'); haxeOutput.push(` var refString = ${nameAccessor} != null ? "<${shortName}-" + ${nameAccessor} + ">" : "<${shortName}-" + (nextId++) + ">";`); } else { haxeOutput.push(` var refString = "<${shortName}-" + (nextId++) + ">";`); } haxeOutput.push(' visitedObjects.set(obj, refString);'); haxeOutput.push(''); haxeOutput.push(' json.writeObjectStart();'); // Write reference string and type haxeOutput.push(' json.writeName("refString");'); haxeOutput.push(' json.writeValue(refString);'); haxeOutput.push(' json.writeName("type");'); haxeOutput.push(` json.writeValue("${shortName}");`); // Write properties for (const property of method.properties) { haxeOutput.push(''); haxeOutput.push(` json.writeName("${property.name}");`); const propertyLines = generatePropertyCode(property, ' ', ir.enumMappings); haxeOutput.push(...propertyLines); } haxeOutput.push(''); haxeOutput.push(' json.writeObjectEnd();'); } haxeOutput.push(' }'); haxeOutput.push(''); } // Add helper methods for special types (following C++ pattern) haxeOutput.push(' // Helper methods for special types'); haxeOutput.push(' private function writeColor(obj:spine.Color):Void {'); haxeOutput.push(' if (obj == null) {'); haxeOutput.push(' json.writeNull();'); haxeOutput.push(' } else {'); haxeOutput.push(' json.writeObjectStart();'); haxeOutput.push(' json.writeName("r");'); haxeOutput.push(' json.writeValue(obj.r);'); haxeOutput.push(' json.writeName("g");'); haxeOutput.push(' json.writeValue(obj.g);'); haxeOutput.push(' json.writeName("b");'); haxeOutput.push(' json.writeValue(obj.b);'); haxeOutput.push(' json.writeName("a");'); haxeOutput.push(' json.writeValue(obj.a);'); haxeOutput.push(' json.writeObjectEnd();'); haxeOutput.push(' }'); haxeOutput.push(' }'); haxeOutput.push(''); haxeOutput.push('}'); return haxeOutput.join('\n'); } async function main(): Promise { try { // Read the IR file const irFile = path.resolve(__dirname, '../output/serializer-ir.json'); if (!fs.existsSync(irFile)) { console.error('Serializer IR not found. Run generate-serializer-ir.ts first.'); process.exit(1); } const ir: SerializerIR = JSON.parse(fs.readFileSync(irFile, 'utf8')); // Generate Haxe serializer from IR const haxeCode = generateHaxeFromIR(ir); // Write the Haxe file const haxeFile = path.resolve( __dirname, '../../spine-haxe/spine-haxe/spine/utils/SkeletonSerializer.hx' ); fs.mkdirSync(path.dirname(haxeFile), { recursive: true }); fs.writeFileSync(haxeFile, haxeCode); console.log(`Generated Haxe serializer from IR: ${haxeFile}`); console.log(`- ${ir.publicMethods.length} public methods`); console.log(`- ${ir.writeMethods.length} write methods`); console.log(`- ${Object.keys(ir.enumMappings).length} enum mappings`); } catch (error: any) { console.error('Error:', error.message); console.error('Stack:', error.stack); process.exit(1); } } // Allow running as a script or importing the function if (import.meta.url === `file://${process.argv[1]}`) { main(); } export { generateHaxeFromIR }; ``` ### 2. JsonWriter Helper Class (`spine-haxe/spine-haxe/spine/utils/JsonWriter.hx`) Based on the pattern from `spine-cpp/tests/JsonWriter.h`, create a Haxe equivalent: ```haxe package spine.utils; enum JsonContext { Object; Array; } class JsonWriter { private var buffer:StringBuf = new StringBuf(); private var needsComma:Bool = false; private var contexts:Array = []; public function new() { buffer = new StringBuf(); needsComma = false; contexts = []; } public function writeObjectStart():Void { writeCommaIfNeeded(); buffer.add("{"); contexts.push(Object); needsComma = false; } public function writeObjectEnd():Void { buffer.add("}"); contexts.pop(); needsComma = true; } public function writeArrayStart():Void { writeCommaIfNeeded(); buffer.add("["); contexts.push(Array); needsComma = false; } public function writeArrayEnd():Void { buffer.add("]"); contexts.pop(); needsComma = true; } public function writeName(name:String):Void { writeCommaIfNeeded(); buffer.add('"${escapeString(name)}":'); needsComma = false; } public function writeValue(value:Dynamic):Void { writeCommaIfNeeded(); if (value == null) { buffer.add("null"); } else if (Std.isOfType(value, String)) { buffer.add('"${escapeString(cast(value, String))}"'); } else if (Std.isOfType(value, Bool)) { buffer.add(value ? "true" : "false"); } else if (Std.isOfType(value, Float) || Std.isOfType(value, Int)) { // Ensure consistent float formatting (C locale style) buffer.add(Std.string(value)); } else { buffer.add(Std.string(value)); } needsComma = true; } public function writeNull():Void { writeCommaIfNeeded(); buffer.add("null"); needsComma = true; } public function getString():String { return buffer.toString(); } private function writeCommaIfNeeded():Void { if (needsComma) { buffer.add(","); } } private function escapeString(str:String):String { // Escape special characters for JSON str = StringTools.replace(str, "\\", "\\\\"); str = StringTools.replace(str, '"', '\\"'); str = StringTools.replace(str, "\n", "\\n"); str = StringTools.replace(str, "\r", "\\r"); str = StringTools.replace(str, "\t", "\\t"); return str; } } ``` ### 3. Haxe HeadlessTest Application (`spine-haxe/tests/HeadlessTest.hx`) Following the pattern from existing HeadlessTest implementations: ```haxe package; import spine.*; import spine.atlas.TextureAtlas; import spine.atlas.TextureAtlasPage; import spine.atlas.TextureLoader; import spine.attachments.AtlasAttachmentLoader; import spine.animation.*; import spine.utils.SkeletonSerializer; import sys.io.File; import haxe.io.Bytes; // Mock texture loader that doesn't require actual texture loading class MockTextureLoader implements TextureLoader { public function new() {} public function load(page:TextureAtlasPage, path:String):Void { // Set mock dimensions - no actual texture loading needed page.width = 1024; page.height = 1024; page.texture = {}; // Empty object as mock texture } public function unload(texture:Dynamic):Void { // Nothing to unload in headless mode } } class HeadlessTest { static function main():Void { var args = Sys.args(); if (args.length < 2) { Sys.stderr().writeString("Usage: HeadlessTest [animation-name]\n"); Sys.exit(1); } var skeletonPath = args[0]; var atlasPath = args[1]; var animationName = args.length >= 3 ? args[2] : null; try { // Load atlas with mock texture loader var textureLoader = new MockTextureLoader(); var atlasContent = File.getContent(atlasPath); var atlas = new TextureAtlas(atlasContent, textureLoader); // Load skeleton data var skeletonData:SkeletonData; var attachmentLoader = new AtlasAttachmentLoader(atlas); if (StringTools.endsWith(skeletonPath, ".json")) { var loader = new SkeletonJson(attachmentLoader); var jsonContent = File.getContent(skeletonPath); skeletonData = loader.readSkeletonData(jsonContent); } else { var loader = new SkeletonBinary(attachmentLoader); var binaryContent = File.getBytes(skeletonPath); skeletonData = loader.readSkeletonData(binaryContent); } // Create serializer var serializer = new SkeletonSerializer(); // Print skeleton data Sys.println("=== SKELETON DATA ==="); Sys.println(serializer.serializeSkeletonData(skeletonData)); // Create skeleton instance var skeleton = new Skeleton(skeletonData); // Handle animation if provided var state:AnimationState = null; if (animationName != null) { var stateData = new AnimationStateData(skeletonData); state = new AnimationState(stateData); var animation = skeletonData.findAnimation(animationName); if (animation == null) { Sys.stderr().writeString('Animation not found: $animationName\n'); Sys.exit(1); } state.setAnimation(0, animation, true); state.update(0.016); state.apply(skeleton); } // Update world transforms (following the pattern from other HeadlessTests) skeleton.updateWorldTransform(Physics.update); // Print skeleton state Sys.println("\n=== SKELETON STATE ==="); Sys.println(serializer.serializeSkeleton(skeleton)); // Print animation state if present if (state != null) { Sys.println("\n=== ANIMATION STATE ==="); Sys.println(serializer.serializeAnimationState(state)); } } catch (e:Dynamic) { Sys.stderr().writeString('Error: $e\n'); Sys.exit(1); } } } ``` ### 4. Build Script (`spine-haxe/build-headless-test.sh`) ```bash #!/bin/bash # Build Haxe HeadlessTest for cross-platform execution # Following pattern from spine-cpp/build.sh set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" echo "Building Haxe HeadlessTest..." # Clean previous build rm -rf build/headless-test # Create build directory mkdir -p build # Compile HeadlessTest to C++ for performance and consistency with other runtimes haxe \ -cp spine-haxe \ -cp tests \ -main HeadlessTest \ -cpp build/headless-test \ -D HXCPP_QUIET # Make executable chmod +x build/headless-test/HeadlessTest echo "Build complete: build/headless-test/HeadlessTest" ``` ### 5. Test Runner Integration (`tests/src/headless-test-runner.ts`) Add Haxe support to the existing test runner. Key changes needed: ```typescript // Line 207: Update supported languages if (!['cpp', 'haxe'].includes(language)) { log_detail(`Invalid target language: ${language}. Must be cpp or haxe`); process.exit(1); } // Add needsHaxeBuild function (similar to needsCppBuild at line 96) function needsHaxeBuild(): boolean { const haxeDir = join(SPINE_ROOT, 'spine-haxe'); const buildDir = join(haxeDir, 'build'); const headlessTest = join(buildDir, 'headless-test', 'HeadlessTest'); try { // Check if executable exists if (!existsSync(headlessTest)) return true; // Get executable modification time const execTime = statSync(headlessTest).mtime.getTime(); // Check Haxe source files const haxeSourceTime = getNewestFileTime(join(haxeDir, 'spine-haxe'), '*.hx'); const testSourceTime = getNewestFileTime(join(haxeDir, 'tests'), '*.hx'); const buildScriptTime = getNewestFileTime(haxeDir, 'build-headless-test.sh'); const newestSourceTime = Math.max(haxeSourceTime, testSourceTime, buildScriptTime); return newestSourceTime > execTime; } catch { return true; } } // Add executeHaxe function (similar to executeCpp at line 321) function executeHaxe(args: TestArgs): string { const haxeDir = join(SPINE_ROOT, 'spine-haxe'); const testsDir = join(haxeDir, 'tests'); if (!existsSync(testsDir)) { log_detail(`Haxe tests directory not found: ${testsDir}`); process.exit(1); } // Check if we need to build if (needsHaxeBuild()) { log_action('Building Haxe HeadlessTest'); try { execSync('./build-headless-test.sh', { cwd: haxeDir, stdio: ['inherit', 'pipe', 'inherit'] }); log_ok(); } catch (error: any) { log_fail(); log_detail(`Haxe build failed: ${error.message}`); process.exit(1); } } // Run the headless test const testArgs = [args.skeletonPath, args.atlasPath]; if (args.animationName) { testArgs.push(args.animationName); } const buildDir = join(haxeDir, 'build'); const headlessTest = join(buildDir, 'headless-test', 'HeadlessTest'); if (!existsSync(headlessTest)) { log_detail(`Haxe headless-test executable not found: ${headlessTest}`); process.exit(1); } log_action('Running Haxe HeadlessTest'); try { const output = execSync(`${headlessTest} ${testArgs.join(' ')}`, { encoding: 'utf8', maxBuffer: 50 * 1024 * 1024 // 50MB buffer for large output }); log_ok(); return output; } catch (error: any) { log_fail(); log_detail(`Haxe execution failed: ${error.message}`); process.exit(1); } } // Update runTestsForFiles function around line 525 to handle Haxe if (language === 'cpp') { targetOutput = executeCpp(testArgs); } else if (language === 'haxe') { targetOutput = executeHaxe(testArgs); } else { log_detail(`Unsupported target language: ${language}`); process.exit(1); } ``` ### 6. Build Integration (`tests/generate-serializers.sh`) Update the serializer generation script to include Haxe: ```bash # Add after C++ generation echo "Generating Haxe serializer..." tsx tests/src/generate-haxe-serializer.ts echo "Type checking Haxe serializer..." cd spine-haxe && haxe -cp spine-haxe --no-output -main spine.utils.SkeletonSerializer cd .. ``` ### 7. File Structure Summary ``` spine-haxe/ ├── spine-haxe/spine/utils/ │ ├── SkeletonSerializer.hx (generated) │ └── JsonWriter.hx (helper class) ├── tests/ │ └── HeadlessTest.hx (console application) ├── build-headless-test.sh (build script) └── build/headless-test/ (compiled executable) └── HeadlessTest tests/src/ ├── generate-haxe-serializer.ts (new generator) └── headless-test-runner.ts (updated with Haxe support) ``` ## Type Checking and Validation ### Compilation Validation Add type checking to the generator to ensure generated code compiles: ```typescript import { execSync } from 'child_process'; async function validateGeneratedHaxeCode(haxeCode: string, outputPath: string): Promise { // Write code to file fs.writeFileSync(outputPath, haxeCode); try { // Attempt compilation without output (type check only) execSync('haxe -cp spine-haxe --no-output -main spine.utils.SkeletonSerializer', { cwd: path.resolve(__dirname, '../../spine-haxe'), stdio: 'pipe' }); console.log('✓ Generated Haxe serializer compiles successfully'); } catch (error: any) { fs.unlinkSync(outputPath); throw new Error(`Generated Haxe serializer failed to compile:\n${error.message}`); } } // Call in main() after generating code await validateGeneratedHaxeCode(haxeCode, haxeFile); ``` ## Key Implementation Notes ### Java → Haxe Property Mapping Based on analysis of `spine-haxe/spine-haxe/spine/` classes: - `obj.getName()` → `obj.name` - `obj.getBones()` → `obj.bones` - `obj.isActive()` → `obj.active` - `obj.getColor()` → `obj.color` ### Enum Handling Haxe enums are different from Java enums. Use `Type.enumConstructor()` to get string representation: ```haxe // For enum serialization json.writeValue(Type.enumConstructor(obj.blendMode)); ``` ### Array Handling Haxe uses `Array` syntax similar to Java, but iteration is different: ```haxe // Haxe iteration for (item in obj.bones) { writeBone(item); } ``` ### Null Safety Haxe has explicit null checking: ```haxe if (obj.skin == null) { json.writeNull(); } else { writeSkin(obj.skin); } ``` ## Testing and Verification ### Cross-Runtime Consistency The test runner will automatically: 1. Build all three runtimes (Java, C++, Haxe) 2. Run identical test cases on same skeleton files 3. Compare JSON outputs for exact matches 4. Report any differences ### Manual Testing ```bash # Generate all serializers ./tests/generate-serializers.sh # Test specific skeleton with all runtimes tsx tests/src/headless-test-runner.ts cpp -s spineboy idle tsx tests/src/headless-test-runner.ts haxe -s spineboy idle # Compare outputs diff tests/output/skeleton-data-cpp-json.json tests/output/skeleton-data-haxe-json.json ``` ## Implementation Checklist - [ ] Create `tests/src/generate-haxe-serializer.ts` - [ ] Create `spine-haxe/spine-haxe/spine/utils/JsonWriter.hx` - [ ] Create `spine-haxe/tests/HeadlessTest.hx` - [ ] Create `spine-haxe/build-headless-test.sh` - [ ] Update `tests/src/headless-test-runner.ts` with Haxe support - [ ] Update `tests/generate-serializers.sh` - [ ] Test with existing skeleton examples - [ ] Verify JSON output matches Java/C++ exactly - [ ] Add to CI pipeline ## Expected Benefits 1. **Cross-Runtime Testing**: Verify Haxe runtime behavior matches Java/C++ 2. **Debugging Support**: Unified JSON format for inspection across all runtimes 3. **API Consistency**: Ensure Haxe API changes don't break compatibility 4. **Quality Assurance**: Automated verification of serialization correctness 5. **Development Velocity**: Fast detection of runtime-specific issues This implementation follows the established patterns while adapting to Haxe's specific language features and build system.