2026-10-17 23:44:36 +07:00
"""Export complete RuntimeFloor group captures from a Client Player.log.
The capture must include FLOOR/GROUP/END records from a read-only Client
GameCoreConfigLoader probe. Inputs and Client revision are explicit arguments.
"""
import argparse
import hashlib
import json
import pathlib
import re
parser = argparse . ArgumentParser ( description = __doc__ )
parser . add_argument ( "log" , type = pathlib . Path )
parser . add_argument ( "--assembly" , type = pathlib . Path , required = True )
parser . add_argument ( "--client-version" , required = True )
parser . add_argument ( "--output" , type = pathlib . Path , required = True )
parser . add_argument ( "--merge" , action = "store_true" , help = "Preserve compatible previously captured floors in output" )
2026-10-21 20:40:13 +07:00
parser . add_argument ( "--dimension-index" , type = int , help = "Use captured SavedValues defaults from this RuntimeFloor dimension-list index" )
parser . add_argument ( "--omit-start-anchor" , action = "store_true" , help = "Keep per-entrance Anchor.json placements; retain the floor anchor only as floor_default_anchor for entries without StartAnchorID" )
2026-10-17 23:44:36 +07:00
args = parser . parse_args ()
floors , completed = {}, set ()
placements = {}
placement_completed = set ()
2026-10-21 20:40:13 +07:00
props_completed = set ()
dimensions = {}
dimension_groups = {}
2026-10-17 23:44:36 +07:00
for line in args . log . read_text ( encoding = "utf-8" , errors = "replace" ) . splitlines ():
2026-10-21 20:40:13 +07:00
if "[GAC_SCENE_DIMENSION] " in line :
row = line . split ( "[GAC_SCENE_DIMENSION] " , 1 )[ 1 ]
groups = re . fullmatch ( r "GROUPS (\d+) (\d+) (\d+) (.+)" , row )
if groups :
floor , index , identity , raw = groups . groups ()
dimension_groups . setdefault ( floor , {})[ int ( index )] = ( int ( identity ), json . loads ( raw ))
continue
if "[GAC_SCENE_SAVED] " in line :
row = line . split ( "[GAC_SCENE_SAVED] " , 1 )[ 1 ]
dimension = re . fullmatch ( r "DIMENSION (\d+) (\d+) (.+)" , row )
if dimension :
floor , index , raw = dimension . groups ()
dimensions . setdefault ( floor , {})[ int ( index )] = json . loads ( raw )
continue
2026-10-17 23:44:36 +07:00
if "[GAC_SCENE_PLACEMENT] " in line :
row = line . split ( "[GAC_SCENE_PLACEMENT] " , 1 )[ 1 ]
end_placement = re . fullmatch ( r "END (\d+)" , row )
if end_placement :
placement_completed . add ( end_placement [ 1 ])
position = re . fullmatch ( r "(ANCHOR|MONSTER) (\d+) (\d+)(?: (\d+))? pos=([^,]+),([^,]+),([^ ]+) rot=(.+)" , row )
if position :
kind , floor , first_id , inst_id , x , y , z , rot = position . groups ()
# Unity metres/degrees -> protocol position/rotation units.
value = { "pos" : { "x" : round ( float ( x ) * 1000 ), "y" : round ( float ( y ) * 1000 ), "z" : round ( float ( z ) * 1000 )}, "rot" : { "x" : 0 , "y" : round ( float ( rot ) * 1000 ), "z" : 0 }}
target = placements . setdefault ( floor , { "monsters" : {}})
if kind == "ANCHOR" :
value [ "ID" ] = int ( first_id )
target [ "start_anchor" ] = value
else :
target [ "monsters" ] . setdefault ( first_id , {})[ inst_id ] = value
continue
2026-10-21 20:40:13 +07:00
if "[GAC_SCENE_PROPS] " in line :
row = line . split ( "[GAC_SCENE_PROPS] " , 1 )[ 1 ]
end_props = re . fullmatch ( r "END (\d+)" , row )
prop = re . fullmatch ( r "PROP (\d+) (\d+) (\d+) (\d+) (\d+) pos=([^,]+),([^,]+),([^ ]+) rot=(.+)" , row )
if end_props :
props_completed . add ( end_props [ 1 ])
elif prop :
floor , group_id , inst_id , prop_id , state , x , y , z , rot = prop . groups ()
if floor in floors :
value = {
"pos" : { "x" : round ( float ( x ) * 1000 ), "y" : round ( float ( y ) * 1000 ), "z" : round ( float ( z ) * 1000 )},
"rot" : { "x" : 0 , "y" : round ( float ( rot ) * 1000 ), "z" : 0 },
"groupId" : int ( group_id ),
"instId" : int ( inst_id ),
"propState" : int ( state ),
"propId" : int ( prop_id ),
}
floors [ floor ] . setdefault ( "props" , {}) . setdefault ( group_id , []) . append ( value )
continue
2026-10-17 23:44:36 +07:00
if "[GAC_SCENE_META]" not in line :
continue
row = line . split ( "[GAC_SCENE_META] " , 1 )[ 1 ]
start = re . fullmatch ( r "FLOOR (\d+) (\d+) startGroup=(\d+) anchor=(\d+) count=(\d+)" , row )
group = re . fullmatch ( r "GROUP (\d+) (\d+) side=(\w+): \d+ hoyo=(true|false) deleted=(true|false)" , row )
end = re . fullmatch ( r "END (\d+)" , row )
if start :
plane , floor , start_group , anchor , count = start . groups ()
2026-10-21 20:40:13 +07:00
floors [ floor ] = { "plane_id" : int ( plane ), "group_count" : int ( count ), "groups" : {}, "props" : {}}
2026-10-17 23:44:36 +07:00
completed . discard ( floor )
elif group :
floor , group_id , side , hoyo , deleted = group . groups ()
if floor in floors :
floors [ floor ][ "groups" ][ group_id ] = { "load_side" : side , "is_hoyo_group" : hoyo == "true" , "is_deleted" : deleted == "true" }
elif end :
completed . add ( end [ 1 ])
2026-10-21 20:40:13 +07:00
if not floors or any ( key not in completed or key not in props_completed or len ( value [ "groups" ]) != value [ "group_count" ] for key , value in floors . items ()):
2026-10-17 23:44:36 +07:00
raise SystemExit ( "Incomplete scene metadata capture; no output written" )
for key , value in placements . items ():
if key in floors and key in placement_completed :
floors [ key ] . update ( value )
2026-10-21 20:40:13 +07:00
for key , floor in floors . items ():
if args . omit_start_anchor :
anchor = floor . pop ( "start_anchor" , None )
if anchor is not None :
floor [ "floor_default_anchor" ] = anchor
if args . dimension_index is not None :
rows = dimensions . get ( key , {}) . get ( args . dimension_index )
if not isinstance ( rows , list ):
raise SystemExit ( f "Missing SavedValues capture for floor { key } , dimension index { args . dimension_index } ; no output written" )
defaults , rules = {}, {}
for row in rows :
if row . get ( "IsDelete" ):
continue
name = row [ "Name" ]
value , minimum , maximum = row [ "DefaultValue" ], row [ "MinValue" ], row [ "MaxValue" ]
allowed = row . get ( "AllowedValues" , [])
if name in defaults or not name or not minimum <= value <= maximum or ( allowed and value not in allowed ):
raise SystemExit ( f "Invalid captured SavedValue { key } / { name } ; no output written" )
defaults [ name ] = value
rules [ name ] = { "DefaultValue" : value , "MinValue" : minimum , "MaxValue" : maximum }
floor [ "saved_values" ], floor [ "saved_value_rules" ] = defaults , rules
selected = dimension_groups . get ( key , {}) . get ( args . dimension_index )
if selected is None :
raise SystemExit ( f "Missing dimension group membership for floor { key } ; no output written" )
identity , ids = selected
if not ids or any ( str ( group ) not in floor [ "groups" ] for group in ids ):
raise SystemExit ( f "Invalid dimension group membership for floor { key } ; no output written" )
floor [ "dimension_id" ] = identity
floor [ "dimension_groups" ] = { str ( identity ): sorted ( set ( ids ))}
2026-10-17 23:44:36 +07:00
result = { "client_version" : args . client_version , "source" : {
"kind" : "Client GameCoreConfigLoader.LoadRtLevelFloorInfo / GroupInstanceCommonMap" ,
"assembly_sha256" : hashlib . file_digest ( args . assembly . open ( "rb" ), "sha256" ) . hexdigest (),
"capture_sha256" : hashlib . sha256 ( args . log . read_bytes ()) . hexdigest (),
}, "floors" : floors }
if args . merge and args . output . exists ():
previous = json . loads ( args . output . read_text ( encoding = "utf-8" ))
if previous [ "client_version" ] != result [ "client_version" ] or previous [ "source" ][ "assembly_sha256" ] != result [ "source" ][ "assembly_sha256" ]:
raise SystemExit ( "Cannot merge metadata from different Clients" )
previous [ "floors" ] . update ( floors )
result [ "floors" ] = previous [ "floors" ]
2026-10-21 20:40:13 +07:00
# Keep provenance and policies for untouched floors when adding a capture.
result [ "source" ] = { ** previous [ "source" ], ** result [ "source" ]}
2026-10-17 23:44:36 +07:00
result [ "source" ][ "previous_capture_sha256" ] = previous [ "source" ] . get ( "previous_capture_sha256" , []) + [ previous [ "source" ][ "capture_sha256" ]]
args . output . write_text ( json . dumps ( result , ensure_ascii = False , indent = 2 ) + " \n " , encoding = "utf-8" )
print ( f "Exported { len ( floors ) } floors to { args . output } " )