From 4add35b4cae098c22cf491f6131cf7a492ae67a1 Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Sun, 27 Sep 2026 21:51:35 +0300 Subject: [PATCH 1/7] feat(wildlife): 17.5 - an animal walks by the world's way where it has one: WildlifeWorld.way (the next corner toward where it is going, asked every half second or on reaching it) steers wandering and going to water or forage, and goal (a spot it can reach) is a wander's target, seeded by the same two draws so the dice do not move; the defaults are the straight line and the old target. way_test: round a wall by its corners, and a wander's target where the world says Co-Authored-By: Claude Opus 5.5 --- packages/ludic.wildlife/README.md | 9 ++ packages/ludic.wildlife/animal.ludic | 3 + packages/ludic.wildlife/ground.ludic | 2 +- packages/ludic.wildlife/index.ludic | 1 + packages/ludic.wildlife/port.ludic | 9 ++ packages/ludic.wildlife/steer.ludic | 9 +- packages/ludic.wildlife/tests/way_test.ludic | 106 +++++++++++++++++++ packages/ludic.wildlife/want.ludic | 3 +- packages/ludic.wildlife/way.ludic | 36 +++++++ 9 files changed, 168 insertions(+), 10 deletions(-) create mode 100644 packages/ludic.wildlife/tests/way_test.ludic create mode 100644 packages/ludic.wildlife/way.ludic diff --git a/packages/ludic.wildlife/README.md b/packages/ludic.wildlife/README.md index c9d5b67b..67cf0306 100644 --- a/packages/ludic.wildlife/README.md +++ b/packages/ludic.wildlife/README.md @@ -46,9 +46,18 @@ export port WildlifeWorld { # every member has a default: a flat dry mead next_id: fn() -> int # an animal's id across machines born: fn(WildAnimal) -> void # draw it placed: fn(WildRange, int) -> void # a range was set out: put its water and forage down + way: fn(WildAnimal, float, float) -> bool # the next corner of its way toward (x, z), into way_x / _z + goal: fn(WildAnimal, x, z, r, seed) -> bool # a spot about r from (x, z) it can walk to, into way_x / _z } ``` +**An animal walks by the world's way where the world has one.** Wandering and going to water or +forage face the next corner `way` gives (asked again every half second, or on reaching the corner), +and a wander's target is a spot `goal` says it can reach, seeded by the same two draws the old +target took - so binding them changes where an animal walks and never the order of the dice. With +no answer (the defaults) it walks straight and turns 75 degrees off water and a climb, as before. +Maroon Lake binds both to its navmesh, walked by the species' habitat's taste. + ## API | | | diff --git a/packages/ludic.wildlife/animal.ludic b/packages/ludic.wildlife/animal.ludic index a5f3ef4c..7ebf36dd 100644 --- a/packages/ludic.wildlife/animal.ludic +++ b/packages/ludic.wildlife/animal.ludic @@ -41,6 +41,9 @@ export property WildAnimal { home_z: float = 0.0 tx: float = 0.0 tz: float = 0.0 + wx: float = 0.0 # the corner of its way it is walking to now (WildlifeWorld.way) + wz: float = 0.0 + way_t: float = 0.0 # seconds until it asks the way again; 0 asks at once thirst: float = 0.0 # 0..100: over 60 it goes looking for water hunger: float = 0.0 scat_t: float = 0.0 # game minutes until it might leave sign diff --git a/packages/ludic.wildlife/ground.ludic b/packages/ludic.wildlife/ground.ludic index ae9339c0..26313853 100644 --- a/packages/ludic.wildlife/ground.ludic +++ b/packages/ludic.wildlife/ground.ludic @@ -31,7 +31,7 @@ export function wildlife_tick_ground(wildlife_st: mut WildlifeState, a: WildAnim } return } - wl_face(a, a.tx, a.tz, 2.0, dt) + wl_head(a, a.tx, a.tz, 2.0, dt) a.speed = s.walk wl_step(wildlife_st, a, dt) if wl_at_target(a, 1.5) or a.timer < 0.0 { diff --git a/packages/ludic.wildlife/index.ludic b/packages/ludic.wildlife/index.ludic index 832aa192..f768c96b 100644 --- a/packages/ludic.wildlife/index.ludic +++ b/packages/ludic.wildlife/index.ludic @@ -16,6 +16,7 @@ import "ranges.ludic" import "migrate.ludic" import "senses.ludic" import "steer.ludic" +import "way.ludic" import "lures.ludic" import "needs.ludic" import "ground.ludic" diff --git a/packages/ludic.wildlife/port.ludic b/packages/ludic.wildlife/port.ludic index 5fb77d1c..7c599b5c 100644 --- a/packages/ludic.wildlife/port.ludic +++ b/packages/ludic.wildlife/port.ludic @@ -46,6 +46,13 @@ export port WildlifeWorld { next_id: fn() -> int = fn wl_next_id born: fn(WildAnimal) -> void = fn wl_nothing # a new animal: draw it placed: fn(WildRange, int) -> void = fn wl_range_nothing # a range was set out + # (a, x, z): the next corner of this animal's way toward (x, z) into way_x / _z; false, the + # straight line (a navmesh's path by its habitat's taste, in Maroon Lake) + way: fn(WildAnimal, float, float) -> bool = fn wl_no_way + # (a, x, z, r, seed): a spot about r from (x, z) this animal can walk to, into way_x / _z + goal: fn(WildAnimal, float, float, float, int) -> bool = fn wl_no_goal + way_x: fn() -> float = fn wl_asked_x + way_z: fn() -> float = fn wl_asked_z } function wl_zero0() -> float { return 0.0 } function wl_zero1(p: int) -> float { return 0.0 } @@ -73,6 +80,8 @@ function wl_no_spot(a: WildAnimal, water: bool, s: WildSpot) -> bool { return fa function wl_no_lure(i: int, l: WildLure) -> bool { return false } function wl_nothing(a: WildAnimal) -> void { } function wl_range_nothing(r: WildRange, i: int) -> void { } +function wl_no_way(a: WildAnimal, x: float, z: float) -> bool { return false } +function wl_no_goal(a: WildAnimal, x: float, z: float, r: float, seed: int) -> bool { return false } function wl_next_id(wildlife_st: mut WildlifeState) -> int { wildlife_st.wl_ids += 1 return wildlife_st.wl_ids diff --git a/packages/ludic.wildlife/steer.ludic b/packages/ludic.wildlife/steer.ludic index b561d5cc..4e73371e 100644 --- a/packages/ludic.wildlife/steer.ludic +++ b/packages/ludic.wildlife/steer.ludic @@ -1,5 +1,5 @@ # steer.ludic - turning toward a point at a rate, a step along the facing that turns away from water -# and a climb, and a target somewhere about home; a walker leaves a print every 2.8 m +# and a climb; a walker leaves a print every 2.8 m (the way there and a target about home: way.ludic) function wl_wrap(a: float) -> float { var r = a while r > 3.1415927 { r = r - WL_TAU } @@ -58,13 +58,6 @@ function wl_push(wildlife_st: mut WildlifeState, a: WildAnimal, s: WildSpecies, return hit } -function wl_pick_target(wildlife_st: WildlifeState, a: WildAnimal, radius: float) -> void { - let ang = wl_rnd(wildlife_st) * WL_TAU - let d = wl_rnd(wildlife_st) * radius - a.tx = a.home_x + Math.cos(ang) * d - a.tz = a.home_z + Math.sin(ang) * d -} - function wl_at_target(a: WildAnimal, r: float) -> bool { let dx = a.tx - a.x let dz = a.tz - a.z diff --git a/packages/ludic.wildlife/tests/way_test.ludic b/packages/ludic.wildlife/tests/way_test.ludic new file mode 100644 index 00000000..b9624030 --- /dev/null +++ b/packages/ludic.wildlife/tests/way_test.ludic @@ -0,0 +1,106 @@ +# way_test.ludic - an animal walks by the world's way where the world has one: round a wall the +# straight line runs into (by the corners WildlifeWorld.way gives), and to a wander target the world +# says it can reach (goal); with no answer it walks straight, as before +import "ludic.wildlife" +import "ludic.base" +import "fake" +program WayTest { + numbers float + bind WildlifeWorld { + ground: fn fk_ground + water: fn fk_water + forest: fn fk_forest + sight: fn fk_sight + push: fn fk_push + pushed_x: fn fk_pushed_x + pushed_z: fn fk_pushed_z + players: fn fk_players + player_on: fn fk_player_on + player_x: fn fk_x + player_y: fn fk_y + player_z: fn fk_z + hidden: fn fk_is_hidden + noise: fn fk_noise_of + scent: fn fk_scent_of + stillness: fn fk_still_of + wind: fn fk_wind_of + wind_dir: fn fk_wind_to + winterness: fn fk_winter_of + about: fn fk_about_of + per_range: fn fk_one + lures: fn fk_count + lure: fn fk_lure + present: fn fk_present + born: fn fk_on_born + spot: fn fk_spot + way: fn wt_way + way_x: fn wt_x + way_z: fn wt_z + goal: fn wt_goal + } + + state WayTestState { + wx: float = 0.0 + wz: float = 0.0 + asked: int = 0 + } + # a wall across z = -40 for |x| < 30: the way goes round its east end, then on to the target + function wt_way(way_test_st: mut WayTestState, a: WildAnimal, x: float, z: float) -> bool { + way_test_st.asked += 1 + way_test_st.wx = x + way_test_st.wz = z + if a.z > -38.0 and a.x < 31.0 { + way_test_st.wx = 32.0 + way_test_st.wz = -40.0 + } + return true + } + # a wander target a known step from home, whatever the seed + function wt_goal(way_test_st: mut WayTestState, a: WildAnimal, x: float, z: float, r: float, seed: int) -> bool { + way_test_st.wx = x + 7.0 + way_test_st.wz = z + 3.0 + return true + } + function wt_x(way_test_st: WayTestState) -> float { return way_test_st.wx } + function wt_z(way_test_st: WayTestState) -> float { return way_test_st.wz } + + test "it walks round the wall by the corners it is given, and asks again as it goes" (wildlife_st: mut WildlifeState, wildlife_tests_fake_st: mut WildlifeTestsFakeState, way_test_st: WayTestState) { + fk_setup(wildlife_st, wildlife_tests_fake_st) + let a = wildlife_make(wildlife_st, 0, 0.0, 0.0, false, 0.0, 0, 1.0, 1) + wildlife_tests_fake_st.fk_px[0] = 400.0 + wildlife_tests_fake_st.fk_pz[0] = 0.0 + wildlife_tests_fake_st.fk_hidden = true + a.state = WILD_WANDER + a.tx = 0.0 + a.tz = -100.0 + a.timer = 300.0 + var east = -100.0 + for k in 0 .. 3000 { + wildlife_tick(wildlife_st, 0.05, 0.0) + if a.z > -42.0 and a.z < -38.0 { east = Math.max(east, a.x) } + } + expect(east > 29.0) + expect(a.z < -60.0) + expect(way_test_st.asked > 10) + } + + test "a wander target is where the world says it can walk" (wildlife_st: mut WildlifeState, wildlife_tests_fake_st: mut WildlifeTestsFakeState, way_test_st: WayTestState) { + fk_setup(wildlife_st, wildlife_tests_fake_st) + let a = wildlife_make(wildlife_st, 0, 5.0, 5.0, false, 0.0, 0, 1.0, 1) + wildlife_tests_fake_st.fk_px[0] = 400.0 + wildlife_tests_fake_st.fk_pz[0] = 0.0 + wildlife_tests_fake_st.fk_hidden = true + a.hunger = 0.0 + a.thirst = 0.0 + a.state = WILD_IDLE + a.timer = -1.0 + var k = 0 + while a.state != WILD_WANDER and k < 2000 { + wildlife_tick(wildlife_st, 0.05, 0.0) + k += 1 + } + expect_eq(a.state, WILD_WANDER) + expect_near(a.tx, a.home_x + 7.0, 0.001) + expect_near(a.tz, a.home_z + 3.0, 0.001) + } +} diff --git a/packages/ludic.wildlife/want.ludic b/packages/ludic.wildlife/want.ludic index 696d6005..ebe42f43 100644 --- a/packages/ludic.wildlife/want.ludic +++ b/packages/ludic.wildlife/want.ludic @@ -10,7 +10,7 @@ function wl_seek(wildlife_st: mut WildlifeState, a: WildAnimal, dt: float) -> bo a.timer = 2.0 return true } - wl_face(a, a.spot_x, a.spot_z, 2.0, dt) + wl_head(a, a.spot_x, a.spot_z, 2.0, dt) a.speed = wl_sp(wildlife_st, a.sp).walk a.tx = a.spot_x a.tz = a.spot_z @@ -44,6 +44,7 @@ function wl_drink(wildlife_st: WildlifeState, a: WildAnimal, dt: float, gh: floa let water = a.thirst > a.hunger if not WildlifeWorld.spot(a, water, wildlife_st.wl_spot_ask) { return false } a.spot = wildlife_st.wl_spot_ask.uid + a.way_t = 0.0 a.spot_x = wildlife_st.wl_spot_ask.x a.spot_z = wildlife_st.wl_spot_ask.z a.spot_water = wildlife_st.wl_spot_ask.water diff --git a/packages/ludic.wildlife/way.ludic b/packages/ludic.wildlife/way.ludic new file mode 100644 index 00000000..ddc00e31 --- /dev/null +++ b/packages/ludic.wildlife/way.ludic @@ -0,0 +1,36 @@ +# way.ludic - where an animal walks: by the world's path to where it is going (WildlifeWorld.way, a +# navmesh by its habitat's taste in Maroon Lake), to a target about home it can reach (goal), and +# straight, as before, where the world has no answer + +# face the way toward (tx, tz): the next corner of the world's path for it, asked again every +# WL_WAY_EVERY seconds or on reaching the corner; without a path, the straight line as before +const WL_WAY_EVERY: float = 0.5 +function wl_head(a: WildAnimal, tx: float, tz: float, rate: float, dt: float) -> void { + a.way_t = a.way_t - dt + let cx = a.wx - a.x + let cz = a.wz - a.z + if a.way_t <= 0.0 or cx * cx + cz * cz < 1.0 { + a.way_t = WL_WAY_EVERY + a.wx = tx + a.wz = tz + if WildlifeWorld.way(a, tx, tz) { + a.wx = WildlifeWorld.way_x() + a.wz = WildlifeWorld.way_z() + } + } + wl_face(a, a.wx, a.wz, rate, dt) +} + +# somewhere about home: a spot the world says it can walk to when it knows, seeded by the same two +# draws the old target took, so the dice are the same whether it knows or not +function wl_pick_target(wildlife_st: WildlifeState, a: WildAnimal, radius: float) -> void { + let ang = wl_rnd(wildlife_st) * WL_TAU + let d = wl_rnd(wildlife_st) * radius + a.tx = a.home_x + Math.cos(ang) * d + a.tz = a.home_z + Math.sin(ang) * d + a.way_t = 0.0 + if WildlifeWorld.goal(a, a.home_x, a.home_z, radius, int(ang * 100000.0) + int(d * 1000.0)) { + a.tx = WildlifeWorld.way_x() + a.tz = WildlifeWorld.way_z() + } +} From ee0768bf3eb34f1bcb4ae2a8a1c16de4a459025c Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Sun, 27 Sep 2026 21:57:34 +0300 Subject: [PATCH 2/7] feat(npc, nav): 17.5 - a person walks by the world's way where it has one (NpcWorld.way: the next corner toward the target, asked every half second, on reaching it or for a new target; the errand begins only at the target itself; the step round and the give-up stay the fallback), way_test round a wall; and ludic.nav reads a mesh file into the shim's own memory and frees it once parsed, so loading a map leaves nothing in Ludic's never-freed heap Co-Authored-By: Claude Opus 5.5 --- packages/ludic.nav/build.ludic | 18 ++-- .../lib/macos-arm64/libludicnav.dylib | 4 +- packages/ludic.nav/native.ludic | 2 + packages/ludic.nav/native/shim/nav_shim.cpp | 1 + packages/ludic.nav/native/shim/nav_tiles.inl | 9 ++ packages/ludic.npc/README.md | 8 ++ packages/ludic.npc/defs.ludic | 5 ++ packages/ludic.npc/index.ludic | 1 + packages/ludic.npc/port.ludic | 6 ++ packages/ludic.npc/tests/way_test.ludic | 82 +++++++++++++++++++ packages/ludic.npc/walk.ludic | 42 +++------- packages/ludic.npc/way.ludic | 45 ++++++++++ 12 files changed, 184 insertions(+), 39 deletions(-) create mode 100644 packages/ludic.npc/tests/way_test.ludic create mode 100644 packages/ludic.npc/way.ludic diff --git a/packages/ludic.nav/build.ludic b/packages/ludic.nav/build.ludic index 15fc5233..6ba8ef4e 100644 --- a/packages/ludic.nav/build.ludic +++ b/packages/ludic.nav/build.ludic @@ -47,8 +47,8 @@ export function nav_polygons(nav_st: NavState, kind: int) -> int { return nvc_polys(h) } -# a kind's mesh written to a file, and read back. Reading holds the file's bytes for good (a slice -# is never given back), so a mesh is loaded once per map, never per frame +# a kind's mesh written to a file, and read back - through the asset pack when it is in one - into the +# shim's own memory, which it frees once the tiles are made, so a map swap leaves nothing behind export function nav_save_file(nav_st: NavState, kind: int, path: string) -> bool { let h = nv_mesh(nav_st, kind) if h == null { return false } @@ -59,8 +59,14 @@ export function nav_save_file(nav_st: NavState, kind: int, path: string) -> bool return Fs.write_bytes(path, buf, n) } export function nav_load_file(nav_st: mut NavState, kind: int, path: string) -> bool { - if not Fs.exists(path) { return false } - let buf = Fs.read_bytes(path) - if buf == null or len(buf) == 0 { return false } - return nv_set(nav_st, kind, nvc_load(buf, len(buf))) + let f = file_open(path, "rb") + if f == null { return false } + file_seek(f, 0, 2) + let n = file_tell(f) + file_seek(f, 0, 0) + let buf = nvc_stage(n) + var got = 0 + if buf != null { got = file_read(f, buf, n) } + file_close(f) + return nv_set(nav_st, kind, nvc_load_staged(buf, got)) } diff --git a/packages/ludic.nav/lib/macos-arm64/libludicnav.dylib b/packages/ludic.nav/lib/macos-arm64/libludicnav.dylib index 4ea3bedc..3941b8a7 100755 --- a/packages/ludic.nav/lib/macos-arm64/libludicnav.dylib +++ b/packages/ludic.nav/lib/macos-arm64/libludicnav.dylib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7d2e4aa97130a76c41dfb6a6b93172e7cbab3fa6249035cc745c8eb162a8487a -size 176736 +oid sha256:10555b7baa5958700f0ce4599e85fc4fc18b9c92afbbacfa64f04b630f42f583 +size 176816 diff --git a/packages/ludic.nav/native.ludic b/packages/ludic.nav/native.ludic index 0d897d54..71d02ce1 100644 --- a/packages/ludic.nav/native.ludic +++ b/packages/ludic.nav/native.ludic @@ -13,3 +13,5 @@ extern function nvc_path(h: pointer, f: int, sx: float, sy: float, sz: float, ex extern function nvc_partial(h: pointer) -> int = "nav_partial" extern function nvc_raycast(h: pointer, f: int, sx: float, sy: float, sz: float, ex: float, ez: float) -> int = "nav_raycast" extern function nvc_random_near(h: pointer, f: int, x: float, y: float, z: float, r: float, seed: int, out: pointer) -> int = "nav_random_near" +extern function nvc_stage(size: int) -> pointer = "nav_stage" +extern function nvc_load_staged(buf: pointer, size: int) -> pointer = "nav_load_staged" diff --git a/packages/ludic.nav/native/shim/nav_shim.cpp b/packages/ludic.nav/native/shim/nav_shim.cpp index f31f049f..4d5b485f 100644 --- a/packages/ludic.nav/native/shim/nav_shim.cpp +++ b/packages/ludic.nav/native/shim/nav_shim.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include diff --git a/packages/ludic.nav/native/shim/nav_tiles.inl b/packages/ludic.nav/native/shim/nav_tiles.inl index 0b1b8ec4..347f7c55 100644 --- a/packages/ludic.nav/native/shim/nav_tiles.inl +++ b/packages/ludic.nav/native/shim/nav_tiles.inl @@ -100,3 +100,12 @@ NAV_SHIM void *nav_load(const unsigned char *buf, int size) { } return nav_open(mesh); } + +// a file read straight into the shim's own memory rather than a Ludic slice (which is never given +// back): nav_stage hands out the room, nav_load_staged parses it and frees it, whatever the outcome +NAV_SHIM unsigned char *nav_stage(int size) { return size > 0 ? static_cast(malloc(size)) : nullptr; } +NAV_SHIM void *nav_load_staged(unsigned char *buf, int size) { + void *h = buf ? nav_load(buf, size) : nullptr; + free(buf); + return h; +} diff --git a/packages/ludic.npc/README.md b/packages/ludic.npc/README.md index 31daab20..42263c87 100644 --- a/packages/ludic.npc/README.md +++ b/packages/ludic.npc/README.md @@ -87,10 +87,18 @@ export port NpcWorld { # every member has a default: a flat dry field at n next_id: fn() -> int, body_of: fn(int) -> int # an id across machines; a look seed's body taken: fn(string) -> bool # a name the place keeps born / gone: fn(NpcPerson) -> void # draw it, stop drawing it + way: fn(NpcPerson, x, z) -> bool, way_x, way_z # the next corner of its way toward (x, z) } export port NpcTalk { say: fn(NpcPerson, int) -> string, greeting: fn(NpcPerson) -> string } ``` +**A person walks by the world's way where the world has one.** `way` is asked for the next corner +toward where it is going - every half second, on reaching the corner, and at once for a new target - +and the person walks corner to corner, but begins its errand only at the target itself. With no +answer (the default) it walks straight and steps round what is in the way, as before; the step +round and the twenty seconds' give-up stay as the fallback. Maroon Lake binds `way` to its navmesh +by a person's taste. + ## API | | | diff --git a/packages/ludic.npc/defs.ludic b/packages/ludic.npc/defs.ludic index 905fee0b..18e3121a 100644 --- a/packages/ludic.npc/defs.ludic +++ b/packages/ludic.npc/defs.ludic @@ -47,6 +47,11 @@ export property NpcPerson { via_on: bool = false # a step round whatever is in the way vx: float = 0.0 vz: float = 0.0 + wx: float = 0.0 # the corner of its way it walks to now (NpcWorld.way) + wz: float = 0.0 + wtx: float = 0.0 # the target that corner was asked for: a new target asks again + wtz: float = 0.0 + way_t: float = 0.0 # seconds until it asks the way again detours: int = 0 fails: int = 0 stall: float = 0.0 # seconds walking without moving diff --git a/packages/ludic.npc/index.ludic b/packages/ludic.npc/index.ludic index ee478ac6..4cb6a00c 100644 --- a/packages/ludic.npc/index.ludic +++ b/packages/ludic.npc/index.ludic @@ -10,6 +10,7 @@ import "ground.ludic" import "spots.ludic" import "routine.ludic" import "walk.ludic" +import "way.ludic" import "act.ludic" import "names.ludic" import "census.ludic" diff --git a/packages/ludic.npc/port.ludic b/packages/ludic.npc/port.ludic index 3448ec87..908588a4 100644 --- a/packages/ludic.npc/port.ludic +++ b/packages/ludic.npc/port.ludic @@ -25,6 +25,11 @@ export port NpcWorld { taken: fn(string) -> bool = fn nw_free # a name the place already uses born: fn(NpcPerson) -> void = fn nw_nothing # a new one (or a slot reused): draw it gone: fn(NpcPerson) -> void = fn nw_nothing # left: stop drawing it + # (p, x, z): the next corner of this person's way toward (x, z) into way_x / _z; false, the + # straight line with its steps round (a navmesh's path by a person's taste, in Maroon Lake) + way: fn(NpcPerson, float, float) -> bool = fn nw_no_way + way_x: fn() -> float = fn nw_asked_x + way_z: fn() -> float = fn nw_asked_z } # what the story says through them: the words of a line whose `fill` is set ("" when it has nothing @@ -76,3 +81,4 @@ export function npc_near(npc_st: mut NpcState, x: float, z: float) -> float { } export function npc_near_i(npc_st: NpcState) -> int { return npc_st.np_near_i } +function nw_no_way(p: NpcPerson, x: float, z: float) -> bool { return false } diff --git a/packages/ludic.npc/tests/way_test.ludic b/packages/ludic.npc/tests/way_test.ludic new file mode 100644 index 00000000..22b726b4 --- /dev/null +++ b/packages/ludic.npc/tests/way_test.ludic @@ -0,0 +1,82 @@ +# way_test.ludic - a person walks by the world's way where the world has one: round a wall the +# straight line runs into, by the corners NpcWorld.way gives, and arrives only where it was going - +# never at a corner +import "ludic.npc" +import "ludic.base" +import "fake" +program NpcWayTest { + numbers float + bind NpcWorld { + ground: fn fk_ground + water: fn fk_water + allowed: fn fk_allowed + push: fn fk_push + pushed_x: fn fk_pushed_x + pushed_z: fn fk_pushed_z + clear: fn fk_clear + hour: fn fk_hour_now + day: fn fk_day_now + players: fn fk_players + player_on: fn fk_player_on + player_x: fn fk_x + player_z: fn fk_z + want: fn fk_wanted + extra: fn fk_extra_kind + taken: fn fk_taken + born: fn fk_on_born + gone: fn fk_on_gone + way: fn wt_way + way_x: fn wt_x + way_z: fn wt_z + } + bind NpcTalk { say: fn tk_say, greeting: fn tk_greeting } + + def NpcActs sit { } + def NpcActs fish { turns: false } + def NpcKinds from "kinds.lres" + def NpcNames from "names.lres" + def NpcLines from "lines.lres" + + function tk_say(p: NpcPerson, line: int) -> string { return "" } + function tk_greeting(p: NpcPerson) -> string { return "" } + function tk_start(npc_st: mut NpcState, p: NpcPerson) -> void { npc_start_near(npc_st, p, 0.0, 0.0, 0.0, 10.0) } + + state WayTestState { + wx: float = 0.0 + wz: float = 0.0 + asked: int = 0 + } + # a wall across x = 20 for |z| < 15 (a straight walk to (38, -25) meets it): the way goes round its + # north end, then on to the target - which is clear of where the fake place forbids stopping + function wt_way(way_test_st: mut WayTestState, p: NpcPerson, x: float, z: float) -> bool { + way_test_st.asked += 1 + way_test_st.wx = x + way_test_st.wz = z + if p.x < 19.0 and p.z < 16.0 { + way_test_st.wx = 20.0 + way_test_st.wz = 17.0 + } + return true + } + function wt_x(way_test_st: WayTestState) -> float { return way_test_st.wx } + function wt_z(way_test_st: WayTestState) -> float { return way_test_st.wz } + + test "it walks round the wall by the corners it is given and begins only where it was going" (npc_st: mut NpcState, npc_tests_fake_st: mut NpcTestsFakeState, way_test_st: WayTestState) { + fk_setup(npc_st, npc_tests_fake_st) + npc_tests_fake_st.fk_want = 1 + let p = npc_spawn(npc_st, NPCK_HIKER) + npc_set_at(p, 0.0, 0.0) + npc_go(npc_st, p, 38.0, -25.0, NPCA_SIT, 30.0) + var north = -100.0 + var k = 0 + while p.act == NPCA_WALK and k < 2000 { + npc_tick(npc_st, 0.05) + if p.x > 19.0 and p.x < 21.0 { north = Math.max(north, p.z) } + k += 1 + } + expect(north > 14.0) + expect_eq(p.act, NPCA_SIT) + expect(npc_d(p.x, p.z, 38.0, -25.0) < 1.3) + expect(way_test_st.asked > 5) + } +} diff --git a/packages/ludic.npc/walk.ludic b/packages/ludic.npc/walk.ludic index 230a7797..90758386 100644 --- a/packages/ludic.npc/walk.ludic +++ b/packages/ludic.npc/walk.ludic @@ -1,6 +1,6 @@ -# walk.ludic - walking to the target: turn toward it, step, slid round a trunk or a tent, never into -# the water and never up a scramble - step round it instead - and give up on a target that gets no -# closer for twenty seconds +# walk.ludic - walking to the target by the world's way where it has one (way.ludic): turn toward the +# next corner, step, slid round a trunk or a tent, never into the water and never up a scramble - step +# round it instead - and give up on a target that gets no closer for twenty seconds const NP_BODY_R: float = 0.35 # a person's width, for the still things export function npc_walk(npc_st: mut NpcState, p: NpcPerson, dt: float) -> void { @@ -10,12 +10,17 @@ export function npc_walk(npc_st: mut NpcState, p: NpcPerson, dt: float) -> void gx = p.vx gz = p.vz } - let dx = gx - p.x - let dz = gz - p.z - if Math.sqrt(dx * dx + dz * dz) < 1.2 { + if npc_d(p.x, p.z, gx, gz) < 1.2 { np_arrive(npc_st, p) return } + if not p.via_on { + np_way(p, dt) + gx = p.wx + gz = p.wz + } + let dx = gx - p.x + let dz = gz - p.z let dg = npc_d(p.x, p.z, p.tx, p.tz) if dg < p.best - 1.0 { p.best = dg @@ -79,28 +84,3 @@ function np_arrive(npc_st: mut NpcState, p: NpcPerson) -> void { p.stall = 0.0 if p.act != NPCA_WALK { np_fact(npc_st, NPC_F_ARRIVED, p, -1) } } - -function np_detour(p: NpcPerson, want: float) -> bool { - for k in 0 .. 8 { - let ang = want + float((k / 2 + 1) * (1 - 2 * (k % 2))) * 0.6 - let x = p.x - Math.sin(ang) * 6.0 - let z = p.z - Math.cos(ang) * 6.0 - if npc_line_ok(p.x, p.z, x, z) { - p.via_on = true - p.vx = x - p.vz = z - p.yaw = ang - return true - } - } - return false -} - -function np_stuck(npc_st: mut NpcState, p: NpcPerson) -> void { - p.fails += 1 - p.via_on = false - if p.fails >= 3 { - p.fails = 0 - npc_wander(npc_st, p) - } else { npc_plan(npc_st, p) } -} diff --git a/packages/ludic.npc/way.ludic b/packages/ludic.npc/way.ludic new file mode 100644 index 00000000..1108897f --- /dev/null +++ b/packages/ludic.npc/way.ludic @@ -0,0 +1,45 @@ +# way.ludic - where a person walks when the straight line will not do: the world's way (NpcWorld.way, +# a navmesh by a person's taste in Maroon Lake), and without one a step round what is in the way, +# and giving the target up after three tries + +# the next corner toward the target, asked again every half second, on reaching it, or for a new target +const NP_WAY_EVERY: float = 0.5 +function np_way(p: NpcPerson, dt: float) -> void { + p.way_t = p.way_t - dt + let fresh = p.wtx != p.tx or p.wtz != p.tz + if p.way_t > 0.0 and not fresh and npc_d(p.x, p.z, p.wx, p.wz) >= 1.0 { return } + p.way_t = NP_WAY_EVERY + p.wtx = p.tx + p.wtz = p.tz + p.wx = p.tx + p.wz = p.tz + if NpcWorld.way(p, p.tx, p.tz) { + p.wx = NpcWorld.way_x() + p.wz = NpcWorld.way_z() + } +} + +function np_detour(p: NpcPerson, want: float) -> bool { + for k in 0 .. 8 { + let ang = want + float((k / 2 + 1) * (1 - 2 * (k % 2))) * 0.6 + let x = p.x - Math.sin(ang) * 6.0 + let z = p.z - Math.cos(ang) * 6.0 + if npc_line_ok(p.x, p.z, x, z) { + p.via_on = true + p.vx = x + p.vz = z + p.yaw = ang + return true + } + } + return false +} + +function np_stuck(npc_st: mut NpcState, p: NpcPerson) -> void { + p.fails += 1 + p.via_on = false + if p.fails >= 3 { + p.fails = 0 + npc_wander(npc_st, p) + } else { npc_plan(npc_st, p) } +} From 6ccbed923ec8e4ccfed1e264708ce5b39648fd74 Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Sun, 27 Sep 2026 21:57:53 +0300 Subject: [PATCH 3/7] build(nav): the Windows library with the staged file read, rebuilt on the PC - 11 tests there Co-Authored-By: Claude Opus 5.5 --- packages/ludic.nav/lib/windows-x64/ludicnav.dll | 4 ++-- packages/ludic.nav/lib/windows-x64/ludicnav.lib | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ludic.nav/lib/windows-x64/ludicnav.dll b/packages/ludic.nav/lib/windows-x64/ludicnav.dll index b5e76515..7a9c6695 100644 --- a/packages/ludic.nav/lib/windows-x64/ludicnav.dll +++ b/packages/ludic.nav/lib/windows-x64/ludicnav.dll @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:300a80b3d3edd283e7911a9899823cf21f1731ea1a41a1190223a0eced900972 -size 336896 +oid sha256:99671e7df79802010fbf132f736d1b5e2681f0cd72ab71e3aa6e7026b837c97c +size 337408 diff --git a/packages/ludic.nav/lib/windows-x64/ludicnav.lib b/packages/ludic.nav/lib/windows-x64/ludicnav.lib index 46a507d1..9e1bdfe3 100644 --- a/packages/ludic.nav/lib/windows-x64/ludicnav.lib +++ b/packages/ludic.nav/lib/windows-x64/ludicnav.lib @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:48f55cfffe5d3e99ae24b68a34b272bfc8ea693ab1f9d849e3e99f17797fee2b -size 3492 +oid sha256:c3e4f7ae19a6113829feffe2ed99e4a8f6b8fb2831c90115f489d1ecf646216a +size 3866 From de9c92b360e7ce5f18a16b625a27c706719942bd Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Sun, 27 Sep 2026 22:01:42 +0300 Subject: [PATCH 4/7] feat(nav): nav_next_corner - a way's next corner read back as the nearest point is, so a walker's question and a goal share one answer and a game needs no state of its own for them Co-Authored-By: Claude Opus 5.5 --- packages/ludic.nav/README.md | 1 + packages/ludic.nav/query.ludic | 10 ++++++++++ packages/ludic.nav/tests/nav_test.ludic | 12 ++++++++++++ 3 files changed, 23 insertions(+) diff --git a/packages/ludic.nav/README.md b/packages/ludic.nav/README.md index a889a2cf..ca214a46 100644 --- a/packages/ludic.nav/README.md +++ b/packages/ludic.nav/README.md @@ -48,6 +48,7 @@ import "ludic.nav" | `nav_save_file(kind, path)`, `nav_load_file(kind, path)`, `nav_reset()` | a baked mesh written and read; every mesh let go | | `nav_nearest(kind, x, y, z) -> bool`, `nav_near_x/y/z()` | the nearest walkable point within a couple of metres | | `nav_path(kind, filter, sx, sy, sz, ex, ey, ez) -> int`, `nav_corners()`, `nav_corner_x/y/z(i)`, `nav_partial(kind)` | a way as corners | +| `nav_next_corner(kind, filter, sx, sy, sz, ex, ey, ez) -> bool` | the next corner of that way, read back as the nearest point is (a walker's one question a frame) | | `nav_straight(kind, filter, sx, sy, sz, ex, ez) -> bool` | does the straight line stay walkable | | `nav_random_near(kind, filter, x, y, z, r, seed) -> bool`, `nav_near_x/y/z()` | a reachable point about r away, from the caller's seed | | `nav_area_cost(kind, filter, area, cost)` | how dear a kind of ground is to cross by that filter | diff --git a/packages/ludic.nav/query.ludic b/packages/ludic.nav/query.ludic index 7718c290..94684688 100644 --- a/packages/ludic.nav/query.ludic +++ b/packages/ludic.nav/query.ludic @@ -19,6 +19,16 @@ export function nav_random_near(nav_st: NavState, kind: int, filter: int, x: flo return nvc_random_near(h, filter, x, y, z, r, seed, nav_st.nv_near) == 1 } +# the next corner of the way from (sx, sy, sz) toward (ex, ey, ez) - the first after the start - read +# back as the nearest point is (nav_near_x / _z), so a walker's question and a goal share one answer +export function nav_next_corner(nav_st: mut NavState, kind: int, filter: int, sx: float, sy: float, sz: float, ex: float, ey: float, ez: float) -> bool { + if nav_path(nav_st, kind, filter, sx, sy, sz, ex, ey, ez) < 2 { return false } + nav_st.nv_near[0] = nav_st.nv_out[3] + nav_st.nv_near[1] = nav_st.nv_out[4] + nav_st.nv_near[2] = nav_st.nv_out[5] + return true +} + # a way from one point to another: its corners, first the start and last the end (or as near the end # as the mesh reaches - nav_partial says which); -1 when either end is off the mesh export function nav_path(nav_st: mut NavState, kind: int, filter: int, sx: float, sy: float, sz: float, ex: float, ey: float, ez: float) -> int { diff --git a/packages/ludic.nav/tests/nav_test.ludic b/packages/ludic.nav/tests/nav_test.ludic index e55bfe08..cfc66f08 100644 --- a/packages/ludic.nav/tests/nav_test.ludic +++ b/packages/ludic.nav/tests/nav_test.ludic @@ -76,6 +76,18 @@ program NavTest { expect_eq(nav_near_z(nav_st), z7) } + test "the next corner is the path's second, read back as the nearest point" (nav_st: mut NavState) { + expect(meadow(nav_st, NAV_PERSON, 0, true)) + let n = nav_path(nav_st, NAV_PERSON, 0, 5.0, 0.0, 20.0, 35.0, 0.0, 20.0) + let cx = nav_corner_x(nav_st, 1) + let cz = nav_corner_z(nav_st, 1) + expect(n >= 3) + expect(nav_next_corner(nav_st, NAV_PERSON, 0, 5.0, 0.0, 20.0, 35.0, 0.0, 20.0)) + expect_near(nav_near_x(nav_st), cx, 0.0001) + expect_near(nav_near_z(nav_st), cz, 0.0001) + expect(not nav_next_corner(nav_st, NAV_PERSON, 0, -50.0, 0.0, -50.0, 35.0, 0.0, 20.0)) + } + test "a filter that dislikes a band of thicket walks round it; the plain one goes through" (nav_st: mut NavState) { expect(meadow(nav_st, NAV_PERSON, 3, false)) nav_area_cost(nav_st, NAV_PERSON, 1, 2, 20.0) From 5e46afdb0f040782f079b3397c1bbf5e80cb2ecc Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Sun, 27 Sep 2026 22:05:07 +0300 Subject: [PATCH 5/7] feat(nav): 17.10's measure - paths asked for, found and cut short since the meshes loaded (nav_asked / nav_found / nav_short) Co-Authored-By: Claude Opus 5.5 --- packages/ludic.nav/README.md | 1 + packages/ludic.nav/query.ludic | 8 ++++++++ packages/ludic.nav/state.ludic | 6 ++++++ 3 files changed, 15 insertions(+) diff --git a/packages/ludic.nav/README.md b/packages/ludic.nav/README.md index ca214a46..0f6eba1f 100644 --- a/packages/ludic.nav/README.md +++ b/packages/ludic.nav/README.md @@ -51,6 +51,7 @@ import "ludic.nav" | `nav_next_corner(kind, filter, sx, sy, sz, ex, ey, ez) -> bool` | the next corner of that way, read back as the nearest point is (a walker's one question a frame) | | `nav_straight(kind, filter, sx, sy, sz, ex, ez) -> bool` | does the straight line stay walkable | | `nav_random_near(kind, filter, x, y, z, r, seed) -> bool`, `nav_near_x/y/z()` | a reachable point about r away, from the caller's seed | +| `nav_asked()`, `nav_found()`, `nav_short()` | paths asked for, found and cut short since the meshes loaded (a game's measure of how often its walkers had a way) | | `nav_area_cost(kind, filter, area, cost)` | how dear a kind of ground is to cross by that filter | ## Tests diff --git a/packages/ludic.nav/query.ludic b/packages/ludic.nav/query.ludic index 94684688..21e6ef6f 100644 --- a/packages/ludic.nav/query.ludic +++ b/packages/ludic.nav/query.ludic @@ -36,9 +36,17 @@ export function nav_path(nav_st: mut NavState, kind: int, filter: int, sx: float nav_st.nv_count = 0 if h == null { return -1 } let n = nvc_path(h, filter, sx, sy, sz, ex, ey, ez, nav_st.nv_out, NAV_CORNERS) + nav_st.nv_asked += 1 if n > 0 { nav_st.nv_count = n } + if n >= 2 { nav_st.nv_found += 1 } + if n > 0 and nvc_partial(h) == 1 { nav_st.nv_short += 1 } return n } + +# how many paths were asked for, found, and cut short (the end out of reach) since the meshes loaded +export function nav_asked(nav_st: NavState) -> int { return nav_st.nv_asked } +export function nav_found(nav_st: NavState) -> int { return nav_st.nv_found } +export function nav_short(nav_st: NavState) -> int { return nav_st.nv_short } export function nav_partial(nav_st: NavState, kind: int) -> bool { let h = nv_mesh(nav_st, kind) return h != null and nvc_partial(h) == 1 diff --git a/packages/ludic.nav/state.ludic b/packages/ludic.nav/state.ludic index 23a1ba8e..cf0f240c 100644 --- a/packages/ludic.nav/state.ludic +++ b/packages/ludic.nav/state.ludic @@ -42,6 +42,9 @@ export state NavState { nv_near: []float = floats(3) nv_cfg: []float = floats(7) # the configuration handed to the shim, filled in place per build nv_empty: []float = floats(1) # what the shim is handed for no cylinders or no footprints + nv_asked: int = 0 # paths asked for, found (two corners or more), and cut short + nv_found: int = 0 + nv_short: int = 0 } function nv_none() -> []pointer { let m = new []pointer @@ -66,4 +69,7 @@ export function nav_drop(nav_st: mut NavState, kind: int) -> void { nv_set(nav_s export function nav_reset(nav_st: mut NavState) -> void { for k in 0 .. NAV_KINDS { nv_set(nav_st, k, null) } nav_st.nv_count = 0 + nav_st.nv_asked = 0 + nav_st.nv_found = 0 + nav_st.nv_short = 0 } From 9539f7bcec6affeed8a8e24e3f0e716d20ac3c57 Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Sun, 27 Sep 2026 22:17:27 +0300 Subject: [PATCH 6/7] perf(json): 23.2 - a parsed tree can be let go (Value.free / Json.free: its nodes and lists, not its strings), a string is parsed in one allocation instead of one per character, the parser's cursor is freed, and gltf_load frees the last file's tree and the text it parsed 300 parses of a 446 KB glTF: 2118 MB before, 528 MB with the one-allocation string, 94 MB freed. A headless Maroon Lake at play: 2334 -> 2195 MB by footprint. Co-Authored-By: Claude Opus 5.5 --- packages/ludic.render3d/gltf.ludic | 4 ++++ runtime/native/namespaces.ludic | 2 ++ runtime/native/value.ludic | 38 ++++++++++++++++++++++++++++-- 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/packages/ludic.render3d/gltf.ludic b/packages/ludic.render3d/gltf.ludic index c991972a..2862f985 100644 --- a/packages/ludic.render3d/gltf.ludic +++ b/packages/ludic.render3d/gltf.ludic @@ -163,7 +163,11 @@ function gltf_load(render3d_st: mut Render3dState, dir: string, file: string, no render3d_st.gltf_dir = dir let text = Fs.read_text(dir + "/" + file) if text == null { print(`gltf: cannot read {dir}/{file}`); return null } + # the last file's tree goes first (it is read only until the next load: ludic.anim's read.ludic), + # and the text once parsed - nothing gives memory back on its own (plan 23 of maroon-lake) + if render3d_st.gltf_doc != null { Json.free(render3d_st.gltf_doc) } render3d_st.gltf_doc = Json.parse(text) + free(text) let buffers = value_get(render3d_st.gltf_doc, "buffers") let bin_uri = value_as_str(value_get(value_at(buffers, 0), "uri")) render3d_st.gltf_bin = file_open(dir + "/" + bin_uri, "rb") diff --git a/runtime/native/namespaces.ludic b/runtime/native/namespaces.ludic index 7c24ea21..3f8627e4 100644 --- a/runtime/native/namespaces.ludic +++ b/runtime/native/namespaces.ludic @@ -478,10 +478,12 @@ namespace Value { alias as_int(value) = value_as_int alias as_float(value) = value_as_float alias as_str(value) = value_as_str + alias free(value) = value_free } namespace Json { alias encode(value) = json_encode alias parse(text) = json_parse + alias free(value) = json_free } namespace Xml { alias parse(text) = xml_parse diff --git a/runtime/native/value.ludic b/runtime/native/value.ludic index dab65b28..cc3e4c0b 100644 --- a/runtime/native/value.ludic +++ b/runtime/native/value.ludic @@ -166,9 +166,23 @@ function jp_skip_ws(p: JP) -> void { function json_parse(s: pointer) -> Val { let p = new JP p.s = s; p.i = 0; p.n = len(s) - return jp_value(p) + let v = jp_value(p) + free(p) + return v } +# Let a parsed tree go once what it describes is built: every node and its lists. Its strings stay - +# a caller may keep a name it read out of the tree - and a tree put together by hand, which may share +# a node or hold a literal, is not for this (Ludic has no collector; plan 23 of maroon-lake). +function value_free(v: Val) -> void { + if v == null { return } + for i in 0 .. len(v.kids) { value_free(v.kids[i]) } + free(v.kids) + free(v.keys) + free(v) +} +function json_free(v: Val) -> void { value_free(v) } + function jp_value(p: JP) -> Val { jp_skip_ws(p) if p.i >= p.n { return value_null() } @@ -182,9 +196,29 @@ function jp_value(p: JP) -> Val { return jp_number(p) } -# read a quoted string (cursor on the opening quote) -> the unescaped contents. +# read a quoted string (cursor on the opening quote) -> the unescaped contents. One allocation for a +# string with no escape in it: built a character at a time, every step was a string never freed function jp_string(p: JP) -> string { p.i += 1 # skip opening quote + let a = p.i + while p.i < p.n { + let c = p.s[p.i] + if c == '"' { + let out = p.s[a..p.i] + p.i += 1 + return out + } + if c == '\\' { + p.i = a + return jp_string_esc(p) + } + p.i += 1 + } + return p.s[a..p.n] +} + +# the same with escapes in it, the rare case, a character at a time +function jp_string_esc(p: JP) -> string { var out = "" while p.i < p.n { let c = p.s[p.i] From e227b75472a64d1f85abf214f3f1b0eb5b3ce8b8 Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Sun, 27 Sep 2026 22:51:06 +0300 Subject: [PATCH 7/7] ludic.save, ludic.telemetry: a parsed tree they refuse is freed (23.2) Co-Authored-By: Claude Opus 5.5 --- packages/ludic.save/read.ludic | 6 ++++-- packages/ludic.telemetry/queue.ludic | 6 +++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/ludic.save/read.ludic b/packages/ludic.save/read.ludic index 454488b2..242c2625 100644 --- a/packages/ludic.save/read.ludic +++ b/packages/ludic.save/read.ludic @@ -6,8 +6,10 @@ export function save_parse(text: string, f: SaveFormat) -> SaveRead { if text == null { return read_new(SAVE_NONE, null) } if not save_text_intact(text) { return read_new(SAVE_CORRUPT, null) } let v = Json.parse(text) - if value_kind(v) != 6 { return read_new(SAVE_CORRUPT, null) } # 6 is an object - if f.is_one != null and not f.is_one(v) { return read_new(SAVE_CORRUPT, null) } + if value_kind(v) != 6 or (f.is_one != null and not f.is_one(v)) { # 6 is an object + if v != null { Json.free(v) } # a refused tree is let go + return read_new(SAVE_CORRUPT, null) + } return read_new(SAVE_OK, v) } diff --git a/packages/ludic.telemetry/queue.ludic b/packages/ludic.telemetry/queue.ludic index db920021..893720d7 100644 --- a/packages/ludic.telemetry/queue.ludic +++ b/packages/ludic.telemetry/queue.ludic @@ -92,6 +92,10 @@ function telemetry__read_obj(path: string) -> Val { let s = Fs.read_text(path) if s == null { return null } let v = Json.parse(s) - if v == null or Value.kind(v) != 6 { return null } + if v == null { return null } + if Value.kind(v) != 6 { + Json.free(v) + return null + } return v }