Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/api/scripts.fp.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions docs/api/scripts.fp.render.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions scripts/fp/fp_context.gd
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ var hud: FpHud
var collision: CollisionWorld
var directory: CityDirectory
var world_root: Node3D # FpMode/World: put in-world FP nodes here, never under CityView
var pack_stream: WebPackStream # web split builds only; inert elsewhere. Request, never download.
var features: Dictionary = {} # feature_id -> FpFeature
var settings: Dictionary = {} # see FpMode.DEFAULT_SETTINGS; change only via mode.apply_settings()
var ws: GDScript = WS # convenience; prefer your own `const WS := preload(...)` for typing
Expand Down
14 changes: 14 additions & 0 deletions scripts/fp/fp_mode.gd
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ func setup(main: Node3D) -> void:
ctx.bus = bus
ctx.directory = directory
ctx.settings = settings
# Web split builds: hand the stream to features before any of them set up, so a feature
# can request a missing pack during setup instead of caching a permanent miss.
ctx.pack_stream = main.get("pack_stream")
if ctx.pack_stream != null:
ctx.pack_stream.pack_ready.connect(_on_pack_ready)
collision = CollisionWorld.new()
collision.name = "Collision"
add_child(collision)
Expand Down Expand Up @@ -148,6 +153,8 @@ func teardown() -> void:
ctx.view.tile_cleared.disconnect(collision.on_tile_cleared)
if ctx.view.tile_built.is_connected(_on_tile_built):
ctx.view.tile_built.disconnect(_on_tile_built)
if ctx.pack_stream != null and ctx.pack_stream.pack_ready.is_connected(_on_pack_ready):
ctx.pack_stream.pack_ready.disconnect(_on_pack_ready)
if hud != null:
hud.queue_free()
hud = null
Expand Down Expand Up @@ -318,6 +325,13 @@ func unstick_player() -> bool:
return true


## Web split builds: forward pack arrivals onto the bus so features refresh pack-dependent
## assets through one channel (and teardown can drop them all by disconnecting here).
func _on_pack_ready(pack: String) -> void:
if bus != null:
bus.pack_ready.emit(pack)


func _on_tile_built(i: int, _holder: Node3D) -> void:
if ctx.view.in_setup or player == null:
return
Expand Down
4 changes: 4 additions & 0 deletions scripts/fp/game_bus.gd
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,7 @@ signal hero_hurt(amount: float, cause: String, health: float, down: bool)
## "chatter" (at a bird it stalks); size is its height at the shoulder (m), so a small dog yaps
## higher. Emitted by the animals feature.
signal animal_called(at: Vector3, call: String, size: float)
## A web background pack finished downloading and mounting (split builds only): its scenes,
## meshes, textures and sounds can now be loaded. FpMode forwards Main's WebPackStream arrivals
## here; features refresh pack-dependent assets through it, never their own HTTPRequests.
signal pack_ready(pack: String)
8 changes: 8 additions & 0 deletions scripts/fp/render/midlod.gd
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ var material: ShaderMaterial
var threaded: bool = true
## false ignores BAKED_DIR and always merges at runtime (tests).
var use_baked: bool = true
## Web split builds: set by render_opt from FpContext. On a baked-twin miss we demand the
## midlod pack once and fall back to a runtime merge; when the pack lands, fresh mesh_for
## calls pick up the baked files. Completed runtime twins are never evicted.
var pack_stream: WebPackStream = null
var baked_loads: int = 0
var _meshes: Dictionary = {}
var _jobs: Dictionary = {}
Expand All @@ -47,6 +51,10 @@ func mesh_for(src: Mesh, path: String) -> ArrayMesh:
return _meshes[id]
if _jobs.has(id):
return null
if use_baked and pack_stream != null and not ResourceLoader.exists(baked_path(src, path)):
# Split build without the pack yet: ask for it (idempotent, bounded by the
# coordinator), then merge at runtime for now.
pack_stream.request_path(baked_path(src, path))
var baked := load_baked(src, path) if use_baked else null
if baked != null:
_meshes[id] = baked
Expand Down
2 changes: 2 additions & 0 deletions scripts/fp/render/render_opt_feature.gd
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ func setup(p_ctx: FpContext) -> void:
hlod.setup(ctx.view, ctx.sim, _imp_root)
# Headless runs (tests) merge on the calling thread, so results are deterministic.
hlod.midlod.threaded = DisplayServer.get_name() != "headless"
# Split web builds: a baked-twin miss demands the midlod pack (bounded, idempotent).
hlod.midlod.pack_stream = ctx.pack_stream
occluders.setup(_occ_root)
ctx.view.tile_built.connect(_on_tile_built)
ctx.view.tile_cleared.connect(_on_tile_cleared)
Expand Down
1 change: 1 addition & 0 deletions scripts/fp/traffic/people_pool.gd.uid
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
uid://h2ow3yu203kp
56 changes: 54 additions & 2 deletions scripts/world/city_view.gd
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,19 @@ var sim: CitySim
var camera: Camera3D
## Web background packs (split builds); null everywhere else. Read-only here: the view asks
## request_path() on an asset miss, Main owns the lifecycle.
var pack_stream: WebPackStream = null
var pack_stream: WebPackStream = null:
set(stream):
if pack_stream != null and pack_stream.pack_ready.is_connected(_on_pack_ready):
pack_stream.pack_ready.disconnect(_on_pack_ready)
pack_stream = stream
if pack_stream != null:
pack_stream.pack_ready.connect(_on_pack_ready)
var overlay_mode: String = "none"

var _scenes: Dictionary = {}
## Scenes waiting on a background pack (pack -> paths). They are NOT in _scenes: a pending
## download must never poison the cache with a null that outlives the pack's arrival.
var _pack_waiting: Dictionary = {}
var _tuned: Dictionary = {}
const MATTE_PREFIXES := ["Roof", "Brick", "Concrete", "Plaster", "Stone", "Trim_Stone", "Sidewalk", "Curb", "Asphalt", "Dirt", "Grass", "Gravel", "Rock", "Turf", "Wood", "Deck", "Siding", "Court", "Track", "Coal", "Garbage"]
const SHEET_METAL_PREFIXES := ["Metal_Corrugated", "Metal_Cladding", "Metal_Plate", "Metal_Rusty", "Shutter", "Container"]
Expand Down Expand Up @@ -371,6 +380,9 @@ func _mk_root(n: String) -> Node3D:

func _scene(path: String) -> PackedScene:
if not _scenes.has(path):
if _pack_gate(path):
# The pack holding this scene is still downloading; try again after pack_ready.
return null
var ps: PackedScene = null
if _warming.has(path):
# A threaded warm-up of this scene is running: wait for it instead of loading it twice.
Expand All @@ -384,6 +396,34 @@ func _scene(path: String) -> PackedScene:
return _scenes[path]


## Split-web request gate: true when path rides in a background pack that has not arrived.
## Demands the pack (the coordinator bounds retries) and records the wait; false everywhere
## else, so desktop and unsplit builds keep their exact old behavior.
func _pack_gate(path: String) -> bool:
if pack_stream == null:
return false
var pack := pack_stream.request_path(path)
if pack.is_empty():
return false
if not _pack_waiting.has(pack):
_pack_waiting[pack] = []
if not _pack_waiting[pack].has(path):
_pack_waiting[pack].append(path)
return true


## A background pack mounted: retry exactly the paths that waited on it. Null entries from
## before the gate existed are dropped; loaded scenes keep their tuned materials untouched.
func _on_pack_ready(pack: String) -> void:
var paths: Array = _pack_waiting.get(pack, [])
_pack_waiting.erase(pack)
for path: String in paths:
if _scenes.has(path) and _scenes[path] == null:
_scenes.erase(path)
if not paths.is_empty():
warm_scenes(paths)


## The realistic GLBs pack ARM maps whose blue (metal) channel the importer reads as fully
## metallic, so roofs, brick and concrete mirrored the sky. Glass and bare metal were also
## mirror-smooth. Fix each shared material once, by name, when its scene first loads.
Expand Down Expand Up @@ -966,6 +1006,10 @@ func _build_trees() -> void:
for tree_name in TREE_PROPS:
var path := R + "props/%s.glb" % tree_name
if not ResourceLoader.exists(path):
# Split web builds: trees ride in the props pack; demand it so the scenes are
# cached on arrival. Re-kitting the chunks on arrival is part of the
# feature-specific arrival coverage step.
_pack_gate(path)
continue
var inst := _inst(path)
if inst == null:
Expand Down Expand Up @@ -1535,7 +1579,12 @@ func warm_scenes(paths: Array) -> void:
if DisplayServer.get_name() == "headless":
return
for p: String in paths:
if not _scenes.has(p) and ResourceLoader.exists(p):
if _scenes.has(p):
continue
if _pack_gate(p):
# Still downloading; _on_pack_ready re-runs warm_scenes for the recorded paths.
continue
if ResourceLoader.exists(p):
_warm_one(p)


Expand All @@ -1544,6 +1593,9 @@ func warm_scenes(paths: Array) -> void:
func _warm_one(path: String) -> bool:
if _scenes.has(path):
return true
if _pack_gate(path):
# Not here yet; report not-ready so callers yield their work instead of caching a miss.
return false
if not _warming.has(path):
if DisplayServer.get_name() == "headless" or not WebSupport.threads_supported() \
or ResourceLoader.load_threaded_request(path, "PackedScene") != OK:
Expand Down
84 changes: 84 additions & 0 deletions tools/pytests/test_pack_closure.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""Option A split-pack closure: boot never statically depends on a background pack.

The split web build ships boot assets in the engine PCK and the decorative packs
(models, sky, posters, audio, ...) as background downloads. A static reference from
boot-classified content to a background-pack asset would either break the boot or
silently poison the loader caches, so this file pins the closure:

- every source tree classifies to the pack the Phase B plan assigns it (Option A:
zones, service geometry, hero and gameplay collision stay in boot);
- no .gd/.tscn/.tres references a resource of another pack (preloads, class-level
loads and scene ext_resources included) — dynamic loads go through the
WebPackStream request gate instead;
- the export PACK_RULES pack set matches the runtime WebPackStream.DEFAULT_ORDER,
so no pack silently queues in the unknown-tail slot.
"""
import re
from pathlib import Path

from tools.web_export import BOOT_PATHS, PACK_RULES, pack_of

ROOT = Path(__file__).resolve().parent.parent.parent


def project_files(suffixes: tuple[str, ...]):
return [
p for p in ROOT.rglob("*")
if p.suffix in suffixes and ".godot" not in p.parts and "build" not in p.parts
]


def test_option_a_classification():
# Zones and the service geometry the first view needs ride in boot (Option A).
for path in (
"models/real/zones/r1/house.glb",
"models/zones/c2/block.glb",
"models/real/buildings/tower.glb",
"models/real/street/sidewalk_concrete_diff.jpg",
"textures/real/grass_albedo.jpg",
"fonts/Inter.ttf",
"sounds/ui/click.ogg",
"scripts/main.gd",
"data/feature_flags.json",
):
assert pack_of(path) == "boot", path
# The decorative packs land in the background.
assert pack_of("env/sky.hdr") == "sky"
assert pack_of("sounds/music/theme.ogg") == "audio"
assert pack_of("sounds/fp/footstep.ogg") == "audio"
assert pack_of("assets/posters/ad.webp") == "posters"
assert pack_of("models/real/props/bin.glb") == "props"
assert pack_of("models/real/vehicles/car.glb") == "traffic"
assert pack_of("models/characters/bodies/Office_Male_01.glb") == "people"
assert pack_of("models/real/animals/dog.glb") == "wildlife"
assert pack_of("models/real/midlod/tower.res") == "midlod"
# Boot paths win over any prefix rule (the hero bodies are inside people/).
for path in BOOT_PATHS:
assert pack_of(path) == "boot", path


def test_no_cross_pack_static_references():
scene_ref = re.compile(r'path="(res://[^"]+)"')
code_ref = re.compile(r'\b(?:preload|load)\(\s*"(res://[^"]+)"\s*\)')
offenders = []
for path in project_files((".gd", ".tscn", ".tres")):
source_pack = pack_of(str(path.relative_to(ROOT)))
text = path.read_text(encoding="utf-8", errors="replace")
found = scene_ref.findall(text) if path.suffix != ".gd" else code_ref.findall(text)
for ref in set(found):
target = ref[len("res://"):]
target_pack = pack_of(target)
if target_pack != source_pack:
offenders.append(f"{path.relative_to(ROOT)} ({source_pack}) -> {target} ({target_pack})")
assert offenders == [], "static cross-pack references break the split build:\n" + "\n".join(offenders)


def test_pack_rules_match_coordinator_default_order():
stream = (ROOT / "scripts/ops/web_pack_stream.gd").read_text(encoding="utf-8")
match = re.search(r"DEFAULT_ORDER(?::\s*Array\[String\])?\s*=\s*\[([^\]]*)\]", stream)
assert match, "WebPackStream.DEFAULT_ORDER not found"
order = re.findall(r'"(\w+)"', match.group(1))
rule_packs = {name for name, _ in PACK_RULES}
assert rule_packs <= set(order), (
f"packs {sorted(rule_packs - set(order))} would queue in the unknown tail"
)
Loading