From 47777669deedede86c6fe967ed46905c152a34c0 Mon Sep 17 00:00:00 2001 From: MagicBot Date: Sat, 24 Oct 2026 11:06:09 -0400 Subject: [PATCH] Add synthetic round-trip test for unexercised CZone structures The shipped CZone.cache only holds plain/trainer mobiles, plain and city props and planar/Targa terrain gens. tests/czone_synthetic.py builds records for every other client branch (banker, shopkeeper, merchant, hireling, pet, minion, inventory contents, lights, containers, all three zone event types, fractal and mesh terrain gens) and checks each is read to the end and rebuilds byte-identical through JSON. Co-Authored-By: Claude Opus 5.5 --- mbEditorPro2.0/tests/czone_synthetic.py | 424 ++++++++++++++++++++++++ 1 file changed, 424 insertions(+) create mode 100644 mbEditorPro2.0/tests/czone_synthetic.py diff --git a/mbEditorPro2.0/tests/czone_synthetic.py b/mbEditorPro2.0/tests/czone_synthetic.py new file mode 100644 index 0000000..3659efb --- /dev/null +++ b/mbEditorPro2.0/tests/czone_synthetic.py @@ -0,0 +1,424 @@ +# • ▌ ▄ ·. ▄▄▄▄· ▄▄▄ .·▄▄▄▄ ▪ ▄▄▄▄▄ ▄▄▄ ▄▄▄·▄▄▄ +# ·██ ▐███▪▐█ ▀█▪ ▀▄.▀·██▪ ██ ██ •██ ▪ ▀▄ █· ▐█ ▄█▀▄ █·▪ +# ▐█ ▌▐▌▐█·▐█▀▀█▄ ▐▀▀▪▄▐█· ▐█▌▐█· ▐█.▪ ▄█▀▄ ▐▀▀▄ ██▀·▐▀▀▄ ▄█▀▄ +# ██ ██▌▐█▌██▄▪▐█ ▐█▄▄▌██. ██ ▐█▌ ▐█▌·▐█▌.▐▌▐█•█▌ ▐█▪·•▐█•█▌▐█▌.▐▌ +# ▀▀ █▪▀▀▀·▀▀▀▀ ▀▀▀ ▀▀▀▀▀• ▀▀▀ ▀▀▀ ▀█▄▀▪.▀ ▀ .▀ .▀ ▀ ▀█▄▀▪ +# Magicbane Emulator Project © 2013 - 2022 +# www.magicbane.com + +# Hand-built records for CZone structures the shipped CZone.cache never uses. +# Run from the tool directory: python tests/czone_synthetic.py + +import json +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..')) + +from arcane.util import ResStream +from arcane.util.ArcFileCache import load_cache_file +from arcane.zones import ArcZone +from arcane.zones.ArcMobile import ArcMobile +from arcane.zones.ArcProp import ArcProp +from arcane.zones.ArcTerrainGen import ArcTerrainGen +from arcane.zones.ArcZoneEvent import ArcZoneEvent +from arcane.zones.mobile.InstanceInfo import InstanceInfo + + +def sample_records(): + mobile_bytes = None + prop_bytes = None + for res_id, data in load_cache_file('CZone.cache'): + zone = ArcZone() + zone.load_binary(ResStream(data)) + if mobile_bytes is None: + for mobile in zone.zone_mobile_info: + if mobile.mobile_type == 1: + out = ResStream() + mobile.mobile_data.save_binary(out) + mobile_bytes = out.get_bytes() + break + if prop_bytes is None: + for prop in zone.zone_prop_info: + if prop.prop_type == 5 and not prop.prop_data.prop_is_light_data: + out = ResStream() + prop.prop_data.save_binary(out) + prop_bytes = out.get_bytes() + break + if mobile_bytes and prop_bytes: + return mobile_bytes, prop_bytes + raise RuntimeError('CZone.cache has no plain mobile or prop to build on') + + +def write_longs(s, values): + for value in values: + s.write_long(value) + + +def write_hash_list(s, values): + s.write_dword(len(values)) + for value in values: + s.write_dword(value) + + +def shopkeeper(s): + s.write_dword(1) + s.write_long(1000) + for value in (0.5, 1.5, 0.25, 1.25): + s.write_float(value) + s.write_dword(0) + s.write_dword(1) + s.write_qword(321) + s.write_long(-4) + + +def merchant(s): + s.write_dword(2) + for value in (0.5, 1.5, 0.25, 1.25, 0.75, 1.75): + s.write_float(value) + s.write_dword(1) + s.write_qword(123) + s.write_long(9) + s.write_dword(0) + + +def product(s): + write_longs(s, (100, 60, 2)) + s.write_bool(True) + s.write_bool(False) + write_longs(s, (3, -1, 7)) + s.write_dword(0x12345678) + s.write_dword(0x9ABCDEF0) + s.write_dword(5) + s.write_bool(True) + for value in (0x0000000100000002, 3, 0xFFFFFFFFFFFFFFFF): + s.write_qword(value) + write_longs(s, (2024, 1, 2, 3, 4, -5)) + + +def minion_element(s): + for value in (1, 2, 3): + s.write_qword(value) + write_longs(s, (10, 20, -30)) + s.write_bool(False) + write_longs(s, range(6)) + s.write_string('Minion') + s.write_string('two') + write_longs(s, (4, 5)) + s.write_string('three') + s.write_string('four') + + +def service_element(s): + s.write_qword(77) + write_longs(s, (1, -2)) + s.write_string('desc') + s.write_string('unit') + write_longs(s, (500, 6, 1)) + + +def hireling(s): + s.write_bool(True) + s.write_long(3) + s.write_string('Title') + write_longs(s, (100, 200, 300)) + s.write_bool(True) + s.write_bool(False) + s.write_long(4) + s.write_dword(2) + write_longs(s, (1, -1)) + s.write_dword(1) + s.write_long(6) + s.write_dword(2) + product(s) + product(s) + s.write_dword(1) + product(s) + s.write_dword(1) + minion_element(s) + s.write_dword(1) + service_element(s) + s.write_dword(1) + write_longs(s, (1, 2, 3, -4)) + s.write_dword(1) + s.write_qword(555) + s.write_dword(1) + s.write_long(7) + s.write_dword(1) + s.write_long(8) + for value in (0xDEADBEEF, 11, 12, 13): + write_hash_list(s, [value]) + s.write_dword(1) + write_longs(s, (1, 2, 3)) + s.write_float(0.5) + s.write_long(40) + s.write_bool(True) + s.write_bool(True) + s.write_bool(False) + write_longs(s, (1, 2)) + + +def minion(s): + hireling(s) + s.write_string('extra') + + +def instance(s, full): + s.write_qword(1) + s.write_qword(2) + s.write_tuple((1.0, 2.0, 3.0)) + s.write_tuple((1.0, 1.0, 1.0)) + for value in (0.0, 0.0, 0.0, 1.0): + s.write_float(value) + s.write_qword(3) + write_longs(s, (-1, -1, 7)) + s.write_bool(True) + s.write_string('Override') + s.write_bool(full) + if full: + s.write_float(100.0) + s.write_float(50.0) + s.write_bool(full) + if full: + s.write_qword(9) + write_longs(s, (1000, -5)) + s.write_float(0.25) + s.write_dword(0x80000001) + s.write_long(3) + s.write_dword(2) + s.write_dword(0x1111) + write_longs(s, (1, -2)) + s.write_bool(True) + s.write_qword(42) + s.write_string('a') + s.write_float(1.5) + s.write_dword(0x2222) + write_longs(s, (3, 4)) + s.write_bool(False) + s.write_dword(17) + s.write_string('b') + s.write_float(2.5) + s.write_long(20) + s.write_bool(full) + if full: + for value in (4, 5, 6): + s.write_qword(value) + s.write_long(2) + s.write_bool(full) + if full: + s.write_qword(8) + s.write_long(3) + s.write_bool(full) + if full: + s.write_bool(True) + s.write_float(2.0) + s.write_dword(0xFFFFFFFF) + s.write_long(-3) + s.write_bool(True) + write_longs(s, range(5)) + s.write_bool(False) + s.write_bool(True) + + +def container(s): + write_longs(s, (1, 2, 0, 0, 1, -1)) + s.write_dword(3) + s.write_dword(1) + for value in (10.0, 20.0, 0.5): + s.write_float(value) + s.write_dword(2) + s.write_float(0.5) + s.write_qword(1234) + s.write_dword(3) + s.write_float(0.25) + s.write_long(-7) + + +def city(s): + s.write_dword(0xAAAA) + s.write_dword(0xBBBB) + write_longs(s, (2, -100)) + + +def trigger(s, timed, has_state): + s.write_long(3) + s.write_bool(True) + s.write_bool(timed) + s.write_bool(True) + if timed: + s.write_float(1.0) + s.write_float(2.0) + s.write_bool(has_state) + if has_state: + s.write_long(-1) + write_hash_list(s, [0x11, 0x22]) + write_hash_list(s, [0x33]) + write_hash_list(s, []) + + +def event_base(s): + s.write_long(60) + s.write_float(5.0) + s.write_string('label') + s.write_string('event') + s.write_dword(0xCAFEBABE) + s.write_tuple((1.0, 2.0, 3.0)) + s.write_string('parent') + s.write_dword(0xBEEF) + s.write_bool(True) + s.write_dword(2) + trigger(s, True, True) + trigger(s, False, False) + + +def resource_event(s): + write_longs(s, (5, 10, 300, 2, -1)) + s.write_bool(True) + s.write_bool(False) + s.write_bool(True) + s.write_dword(2) + s.write_qword(1000) + s.write_float(0.5) + s.write_qword(2000) + s.write_float(0.25) + + +def event_state(s, extras): + write_longs(s, (2, 1)) + s.write_bool(True) + s.write_bool(False) + s.write_bool(True) + for value in (0x1, 0x2, 0x3): + s.write_dword(value) + s.write_bool(extras) + if extras: + write_longs(s, (1, 2, 3)) + s.write_bool(extras) + if extras: + s.write_dword(0x4) + s.write_bool(False) + s.write_long(8) + + +def state_event(s, prop_record, mobile_record): + s.write_long(1) + s.write_bool(True) + s.write_dword(2) + event_state(s, True) + event_state(s, False) + s.write_dword(1) + s.write_long(1) + s.write_bytes(prop_record) + s.write_dword(1) + s.write_long(2) + s.write_bytes(mobile_record) + + +def fractal_terrain(s): + for value in (1.0, 2.0, 3.0, 4.0): + s.write_float(value) + s.write_long(5) + s.write_float(0.5) + s.write_float(0.25) + write_longs(s, (7, -1)) + + +def mesh_terrain(s): + for value in (1.0, 2.0, 3.0, 4.0): + s.write_float(value) + s.write_qword(99) + + +def build(type_id, *parts): + s = ResStream() + s.write_dword(type_id) + for part in parts: + if isinstance(part, bytes): + s.write_bytes(part) + else: + part(s) + return s.get_bytes() + + +def mobile_with_contents(mobile_bytes): + contents = ResStream() + instance(contents, True) + instance(contents, False) + contents_bytes = contents.get_bytes() + instances = [] + stream = ResStream(contents_bytes) + for _ in range(2): + item = InstanceInfo() + item.load_binary(stream) + instances.append(item) + assert stream.buffer.tell() == len(contents_bytes), 'InstanceInfo left bytes unread' + mobile = ArcMobile() + mobile.load_binary(ResStream(build(1, mobile_bytes))) + mobile.mobile_data.mobile_has_content = True + mobile.mobile_data.mobile_inventory_contents = instances + out = ResStream() + mobile.save_binary(out) + record = out.get_bytes() + assert contents_bytes in record, 'InstanceInfo did not save back to the same bytes' + return record + + +def prop_with_light(prop_bytes): + prop = ArcProp() + prop.load_binary(ResStream(build(5, prop_bytes))) + prop.prop_data.prop_is_light_data = True + prop.prop_data.prop_light_color = [1.0, 0.5, 0.25, 1.0] + prop.prop_data.prop_light_radius = 12.0 + prop.prop_data.prop_light_flicker = -1 + prop.prop_data.prop_light_cube_map = 2 + out = ResStream() + prop.save_binary(out) + return out.get_bytes() + + +def round_trip(name, record_class, data): + stream = ResStream(data) + record = record_class() + record.load_binary(stream) + unread = len(data) - stream.buffer.tell() + rebuilt = record_class() + rebuilt.load_json(json.loads(json.dumps(record.save_json()))) + out = ResStream() + rebuilt.save_binary(out) + identical = out.get_bytes() == data + passed = unread == 0 and identical + print(f'{name:22s} unread={unread} identical={identical} {"OK" if passed else "FAIL"}') + return passed + + +def main(): + mobile_bytes, prop_bytes = sample_records() + mobile_record = build(1, mobile_bytes) + prop_record = build(5, prop_bytes) + cases = [ + ('mobile BANKER', ArcMobile, build(2, mobile_bytes)), + ('mobile SHOPKEEPER', ArcMobile, build(3, mobile_bytes, shopkeeper)), + ('mobile MERCHANT', ArcMobile, build(8, mobile_bytes, merchant)), + ('mobile HIRELING', ArcMobile, build(9, mobile_bytes, merchant, hireling)), + ('mobile PET', ArcMobile, build(10, mobile_bytes, merchant, hireling)), + ('mobile MINION', ArcMobile, build(11, mobile_bytes, merchant, minion)), + ('mobile inventory', ArcMobile, mobile_with_contents(mobile_bytes)), + ('prop light', ArcProp, prop_with_light(prop_bytes)), + ('prop CITY', ArcProp, build(6, prop_bytes, city)), + ('prop CONTAINER', ArcProp, build(7, prop_bytes, container)), + ('event EVENT', ArcZoneEvent, build(1, event_base)), + ('event RESOURCE', ArcZoneEvent, build(2, event_base, resource_event)), + ('event STATE', ArcZoneEvent, build(3, event_base, lambda s: state_event(s, prop_record, mobile_record))), + ('terrain PEAKED', ArcTerrainGen, build(1, fractal_terrain)), + ('terrain RIDGED', ArcTerrainGen, build(2, fractal_terrain)), + ('terrain ROLLING', ArcTerrainGen, build(3, fractal_terrain)), + ('terrain MESA', ArcTerrainGen, build(5, fractal_terrain)), + ('terrain MESH', ArcTerrainGen, build(6, mesh_terrain)), + ] + results = [round_trip(name, record_class, data) for name, record_class, data in cases] + print(f'{results.count(True)}/{len(results)} passed') + return 0 if all(results) else 1 + + +if __name__ == '__main__': + sys.exit(main())