"""Dumps the actors of one or more levels to JSON, so a reference level (a pack's demo map) can be read without opening it in the editor: class, label, transform, and the properties that matter for lighting, sky, fog, post-processing and landscape. Written for reading Elite_RockyMeadows' demo maps while building L_World. UnrealEditor-Cmd.exe Salty.uproject -run=pythonscript -script="Scripts/Authoring/dump_level.py /Game/Elite_RockyMeadows/Maps/Rocky_Meadows_01" Levels come from the script arguments, else from the SALTY_DUMP_LEVELS environment variable (semicolon separated), else the pack's first demo map. Output: Saved/Authoring/.json, one per level. """ import json import os import sys import unreal HERE = os.path.dirname(os.path.abspath(__file__)) OUT_DIR = os.path.normpath(os.path.join(HERE, "..", "..", "Saved", "Authoring")) DEFAULT_LEVELS = ["/Game/Elite_RockyMeadows/Maps/Rocky_Meadows_01"] # Component class name -> editor properties worth reading. Missing ones are skipped, so the list can be generous. COMPONENT_PROPERTIES = { "DirectionalLightComponent": [ "intensity", "light_color", "use_temperature", "temperature", "light_function_material", "light_function_scale", "light_function_fade_distance", "atmosphere_sun_light", "cast_shadows", "dynamic_shadow_distance_movable_light", "dynamic_shadow_distance_stationary_light", "num_dynamic_shadow_cascades", "cascade_distribution_exponent", "cascade_transition_fraction", "shadow_distance_fadeout_fraction", "light_source_angle", "shadow_bias", "volumetric_scattering_intensity", "cast_cloud_shadows", "cast_volumetric_shadow", "indirect_lighting_intensity", "mobility", "specular_scale", ], "SkyLightComponent": [ "intensity", "light_color", "source_type", "cubemap", "cubemap_resolution", "source_cubemap_angle", "real_time_capture", "lower_hemisphere_color", "sky_distance_threshold", "cast_shadows", "volumetric_scattering_intensity", "indirect_lighting_intensity", "occlusion_max_distance", "mobility", ], "ExponentialHeightFogComponent": [ "fog_density", "fog_height_falloff", "second_fog_data", "fog_inscattering_luminance", "skybox_inscattering_color_cubemap", "fog_inscattering_luminance_scale", "directional_inscattering_luminance", "directional_inscattering_exponent", "directional_inscattering_start_distance", "fog_max_opacity", "start_distance", "end_distance", "fog_cutoff_distance", "volumetric_fog", "volumetric_fog_scattering_distribution", "volumetric_fog_albedo", "volumetric_fog_emissive", "volumetric_fog_extinction_scale", "volumetric_fog_distance", "volumetric_fog_start_distance", "volumetric_fog_near_fade_in_distance", "volumetric_fog_static_lighting_scattering_intensity", ], "StaticMeshComponent": ["static_mesh", "cast_shadow", "visible"], "SkyAtmosphereComponent": ["rayleigh_scattering_scale", "mie_scattering_scale", "aerial_pespective_view_distance_scale"], "VolumetricCloudComponent": ["layer_bottom_altitude", "layer_height", "material"], } ACTOR_PROPERTIES = { "Landscape": ["landscape_material", "landscape_hole_material", "component_size_quads", "subsection_size_quads", "num_subsections", "static_lighting_lod", "lod_distribution_setting", "lod0_distribution_setting", "lod0_screen_size", "streaming_distance_multiplier", "collision_mip_level", "nanite_lod_index", "enable_nanite", "target_display_order"], "LandscapeStreamingProxy": ["landscape_material", "component_size_quads", "subsection_size_quads", "num_subsections"], "PostProcessVolume": ["unbound", "enabled", "priority", "blend_weight"], "Actor": ["hidden"], } def encode(value): """A JSON-able rendering of a Python-wrapped Unreal value.""" if value is None or isinstance(value, (bool, int, float, str)): return value if isinstance(value, unreal.Object): return value.get_path_name() if isinstance(value, (unreal.Name, unreal.Text)): return str(value) if isinstance(value, (unreal.Vector, unreal.Rotator, unreal.LinearColor, unreal.Color, unreal.Vector2D, unreal.IntPoint)): return list(value.to_tuple()) if isinstance(value, unreal.Array): return [encode(v) for v in value] return str(value) def read_properties(obj, names): out = {} for name in names: try: out[name] = encode(obj.get_editor_property(name)) except Exception: pass return out def overridden_post_process(settings): """The FPostProcessSettings fields whose override flag is set, with their values.""" out = {} for name in dir(settings): if not name.startswith("override_"): continue try: if not settings.get_editor_property(name): continue field = name[len("override_"):] out[field] = encode(settings.get_editor_property(field)) except Exception: pass return out def dump_actor(actor): class_name = actor.get_class().get_name() record = { "class": class_name, "label": actor.get_actor_label(), "location": list(actor.get_actor_location().to_tuple()), "rotation": list(actor.get_actor_rotation().to_tuple()), # the tuple order is (roll, pitch, yaw) "scale": list(actor.get_actor_scale3d().to_tuple()), } for key, names in ACTOR_PROPERTIES.items(): if key == "Actor" or class_name.startswith(key): record.update(read_properties(actor, names)) if class_name.startswith("PostProcessVolume"): try: record["settings"] = overridden_post_process(actor.get_editor_property("settings")) except Exception as error: record["settings_error"] = str(error) components = {} for component in actor.get_components_by_class(unreal.ActorComponent): component_class = component.get_class().get_name() for key, names in COMPONENT_PROPERTIES.items(): if component_class == key or component_class.startswith(key): entry = read_properties(component, names) if isinstance(component, unreal.SceneComponent): entry["relative_location"] = list(component.get_editor_property("relative_location").to_tuple()) entry["relative_rotation"] = list(component.get_editor_property("relative_rotation").to_tuple()) entry["relative_scale3d"] = list(component.get_editor_property("relative_scale3d").to_tuple()) if isinstance(component, unreal.MeshComponent): entry["materials"] = [encode(m) for m in component.get_materials()] components[f"{component_class}:{component.get_name()}"] = entry if class_name.startswith("Landscape"): try: components["landscape_component_count"] = len(actor.get_editor_property("landscape_components")) except Exception: pass record["components"] = components return record def dump_level(level_path): unreal.log(f"dump_level: loading {level_path}") level_subsystem = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem) if not level_subsystem.load_level(level_path): unreal.log_error(f"dump_level: could not load {level_path}") return actor_subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem) actors = [dump_actor(actor) for actor in actor_subsystem.get_all_level_actors()] os.makedirs(OUT_DIR, exist_ok=True) out_path = os.path.join(OUT_DIR, level_path.rsplit("/", 1)[-1] + ".json") with open(out_path, "w", encoding="utf-8") as f: json.dump({"level": level_path, "actors": actors}, f, indent=2) unreal.log(f"dump_level: {len(actors)} actors of {level_path} written to {out_path}") def main(): levels = [a for a in sys.argv[1:] if a.startswith("/")] if not levels and os.environ.get("SALTY_DUMP_LEVELS"): levels = [p for p in os.environ["SALTY_DUMP_LEVELS"].split(";") if p] for level in levels or DEFAULT_LEVELS: dump_level(level) main()