From 24ca2d346805a4f7a2c074ad0a25446ef72f15e1 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Thu, 22 Oct 2026 02:04:39 -0400 Subject: [PATCH] VRLimit: Orbis Tower's view range narrows its mob-capacity bounds (r25 0xa44c3a), 14 -> 11 --- agent_docs/combat.md | 4 ++- game-data/src/footholds.rs | 45 +++++++++++++++++++++++++++++++++ game-data/src/lib.rs | 40 +++++++++++++++++++++++++++++ runtime/src/actor/field_mobs.rs | 8 ++++-- runtime/src/actor/registry.rs | 5 +++- 5 files changed, 98 insertions(+), 4 deletions(-) diff --git a/agent_docs/combat.md b/agent_docs/combat.md index 475c16cdf..70bdb81b1 100644 --- a/agent_docs/combat.md +++ b/agent_docs/combat.md @@ -13,7 +13,9 @@ templates from `Mob.nx` (`maxHP`, `exp`, `PADamage`, `bodyAttack`, (`field_mobs::mob_capacity`): one per 128,000 square pixels of its footholds' bounds (`Footholds::bounds`, the client's: 30 px in from the sides, 300 px above the top, 10 px below the bottom; the server adds 10 - px a side), counted as at least 800 wide and 600 high after taking 450 + px a side). On a map with `info/VRLimit` (only Orbis Tower) the + view range pulls those bounds in as the client does + (`Footholds::bounds_within`). The area is counted as at least 800 wide and 600 high after taking 450 off the height, times `info/mobRate`, from 1 to 40. With more than half that many users on the field it grows, up to twice as many at twice as many users. diff --git a/game-data/src/footholds.rs b/game-data/src/footholds.rs index 8c18756eb..6b236274d 100644 --- a/game-data/src/footholds.rs +++ b/game-data/src/footholds.rs @@ -82,6 +82,34 @@ impl Footholds { )) } + /// The map's bounds (`bounds`) inside its view range, for a map with + /// `info/VRLimit` (`FieldTemplate::view_limit`), as the v83 client + /// narrows them (r25 0xa44c3a): the left edge to 20 px inside the view's, + /// the right to 20 px inside, the top to 65 px below and the bottom to + /// the view's bottom, each only inward and only when the view's edge is + /// not 0. + pub fn bounds_within( + &self, + view: Option<(i32, i32, i32, i32)>, + ) -> Option<(i32, i32, i32, i32)> { + let (mut left, mut top, mut right, mut bottom) = self.bounds()?; + if let Some((view_left, view_top, view_right, view_bottom)) = view { + if view_left != 0 { + left = left.max(view_left + 20); + } + if view_top != 0 { + top = top.max(view_top + 65); + } + if view_right != 0 { + right = right.min(view_right - 20); + } + if view_bottom != 0 { + bottom = bottom.min(view_bottom); + } + } + Some((left, top, right, bottom)) + } + /// The highest ground at `x` that is at or below `y`. pub fn ground_below(&self, x: i16, y: i16) -> Option { self.lines @@ -172,6 +200,23 @@ mod tests { assert_eq!(Footholds::default().bounds(), None); } + #[test] + fn a_view_range_only_pulls_the_bounds_in() { + let cliff = cliff(); + assert_eq!(cliff.bounds_within(None), cliff.bounds()); + // Left and top move in; the right and bottom view edges lie outside. + assert_eq!( + cliff.bounds_within(Some((-200, -300, 400, 500))), + Some((-180, -200, 270, 210)) + ); + // Right and bottom move in; an edge of 0 is left alone. + assert_eq!( + cliff.bounds_within(Some((0, 0, 200, 150))), + Some((-270, -200, 180, 150)) + ); + assert_eq!(Footholds::default().bounds_within(Some((1, 1, 1, 1))), None); + } + #[test] fn drops_land_on_the_ground_below_their_column() { let footholds = cliff(); diff --git a/game-data/src/lib.rs b/game-data/src/lib.rs index 57316c799..ab02f164d 100644 --- a/game-data/src/lib.rs +++ b/game-data/src/lib.rs @@ -240,6 +240,10 @@ pub struct FieldTemplate { pub mob_spawns: Vec, pub reactors: Vec, pub mob_rate: f64, + /// `info/VRLeft`, `VRTop`, `VRRight` and `VRBottom` when `info/VRLimit` + /// is set (only Orbis Tower, 200082300): the view range that narrows + /// the map's bounds (`Footholds::bounds_within`). + pub view_limit: Option<(i32, i32, i32, i32)>, /// `info/recovery`: how much faster players recover HP and MP at rest, /// e.g. 2 in the Sleepywood sauna. pub recovery: f64, @@ -580,6 +584,19 @@ fn build_field_template( .transpose()? .flatten() .unwrap_or(1.0); + let view_limit = match nx.child_by_name(map_node_idx, "info")? { + Some(info) if nx.int_child(info, "VRLimit")?.unwrap_or(0) != 0 => { + let edge = + |key| -> Result { Ok(nx.int_child(info, key)?.unwrap_or(0)) }; + Some(( + edge("VRLeft")?, + edge("VRTop")?, + edge("VRRight")?, + edge("VRBottom")?, + )) + } + _ => None, + }; let time_limit = nx .child_by_name(map_node_idx, "info")? .map(|info| nx.int_child(info, "timeLimit")) @@ -682,6 +699,7 @@ fn build_field_template( mob_spawns, reactors, mob_rate, + view_limit, recovery, town, field_limit, @@ -1296,6 +1314,7 @@ mod tests { mob_spawns: Vec::new(), reactors: Vec::new(), mob_rate: 1.0, + view_limit: None, recovery: 1.0, town: false, field_limit: 0, @@ -1371,6 +1390,7 @@ mod tests { map_npcs: Vec::new(), mob_spawns: Vec::new(), mob_rate: 1.0, + view_limit: None, recovery: 1.0, town: false, field_limit: 0, @@ -1418,6 +1438,26 @@ mod tests { assert!(henesys.may_use(2000000) && henesys.may_drop()); } + #[test] + fn orbis_towers_view_range_narrows_its_bounds() { + let Some(path) = test_assets::path("Map.nx") else { + return; + }; + let game_data = GameData::load_from_nx_map(&path).expect("load map nx"); + let tower = game_data.field(200082300).expect("Orbis Tower"); + assert_eq!(tower.view_limit, Some((-400, -2239, 400, 285))); + // Only the top moves in, to 65 px below the view's. + assert_eq!(tower.footholds.bounds(), Some((-335, -2610, 335, 160))); + assert_eq!( + tower.footholds.bounds_within(tower.view_limit), + Some((-335, -2174, 335, 160)) + ); + assert_eq!( + game_data.field(100000000).expect("Henesys").view_limit, + None + ); + } + #[test] fn loads_map_npcs_from_assets_map_nx() { let Some(path) = test_assets::path("Map.nx") else { diff --git a/runtime/src/actor/field_mobs.rs b/runtime/src/actor/field_mobs.rs index 4e2a94db8..849f41240 100644 --- a/runtime/src/actor/field_mobs.rs +++ b/runtime/src/actor/field_mobs.rs @@ -1318,8 +1318,9 @@ fn near(mob: (i16, i16), at: (i16, i16)) -> bool { /// How many mobs a field holds with few users on it (CField's mob /// capacity): one per 128,000 square pixels of the map, at least 800 wide /// and 600 high after taking off 450, times its `mobRate`, from 1 to 40. -/// `bounds` are its footholds' bounds (`Footholds::bounds`), which the -/// server grows by 10 px on every side. +/// `bounds` are its footholds' bounds inside any view range +/// (`Footholds::bounds_within`), which the server grows by 10 px on every +/// side. pub fn mob_capacity(bounds: Option<(i32, i32, i32, i32)>, mob_rate: f64) -> usize { let (width, height) = bounds.map_or((0, 0), |(left, top, right, bottom)| { (right - left + 20, bottom - top + 20) @@ -2240,6 +2241,9 @@ mod tests { assert_eq!(mob_capacity(Some((0, 0, 1580, 1030)), 1.0), 7); assert_eq!(mob_capacity(Some((0, 0, 9000, 9000)), 1.0), 40); assert_eq!(mob_capacity(None, 0.0), 1); + // Orbis Tower, by its footholds and then inside its view range. + assert_eq!(mob_capacity(Some((-335, -2610, 335, 160)), 1.0), 14); + assert_eq!(mob_capacity(Some((-335, -2174, 335, 160)), 1.0), 11); } #[test] diff --git a/runtime/src/actor/registry.rs b/runtime/src/actor/registry.rs index ce6d9ac2a..9db035548 100644 --- a/runtime/src/actor/registry.rs +++ b/runtime/src/actor/registry.rs @@ -143,7 +143,10 @@ fn load_field_mobs(field_key: FieldKey) -> MobSet { } } })) - .with_capacity(mob_capacity(field.footholds.bounds(), field.mob_rate)); + .with_capacity(mob_capacity( + field.footholds.bounds_within(field.view_limit), + field.mob_rate, + )); if full_spawn() { mobs.filling_every_point() } else {