diff --git a/assets/maps/abyss_svg_height_attack.json.gz b/assets/maps/abyss_svg_height_attack.json.gz index 29550189..6d237fb7 100644 Binary files a/assets/maps/abyss_svg_height_attack.json.gz and b/assets/maps/abyss_svg_height_attack.json.gz differ diff --git a/assets/maps/abyss_svg_height_defense.json.gz b/assets/maps/abyss_svg_height_defense.json.gz index 66f1e399..dd732678 100644 Binary files a/assets/maps/abyss_svg_height_defense.json.gz and b/assets/maps/abyss_svg_height_defense.json.gz differ diff --git a/assets/maps/ascent_svg_height_attack.json.gz b/assets/maps/ascent_svg_height_attack.json.gz index 06dbb0e0..0adb5fc3 100644 Binary files a/assets/maps/ascent_svg_height_attack.json.gz and b/assets/maps/ascent_svg_height_attack.json.gz differ diff --git a/assets/maps/ascent_svg_height_defense.json.gz b/assets/maps/ascent_svg_height_defense.json.gz index 06661e85..ab6c6f01 100644 Binary files a/assets/maps/ascent_svg_height_defense.json.gz and b/assets/maps/ascent_svg_height_defense.json.gz differ diff --git a/assets/maps/bind_svg_height_attack.json.gz b/assets/maps/bind_svg_height_attack.json.gz index 090c1894..152c4194 100644 Binary files a/assets/maps/bind_svg_height_attack.json.gz and b/assets/maps/bind_svg_height_attack.json.gz differ diff --git a/assets/maps/bind_svg_height_defense.json.gz b/assets/maps/bind_svg_height_defense.json.gz index 3218a419..affab695 100644 Binary files a/assets/maps/bind_svg_height_defense.json.gz and b/assets/maps/bind_svg_height_defense.json.gz differ diff --git a/assets/maps/breeze_svg_height_attack.json.gz b/assets/maps/breeze_svg_height_attack.json.gz index 24449dd6..78c15679 100644 Binary files a/assets/maps/breeze_svg_height_attack.json.gz and b/assets/maps/breeze_svg_height_attack.json.gz differ diff --git a/assets/maps/breeze_svg_height_defense.json.gz b/assets/maps/breeze_svg_height_defense.json.gz index 95dc692a..4272b16c 100644 Binary files a/assets/maps/breeze_svg_height_defense.json.gz and b/assets/maps/breeze_svg_height_defense.json.gz differ diff --git a/assets/maps/corrode_svg_height_attack.json.gz b/assets/maps/corrode_svg_height_attack.json.gz index bd210a21..3f525062 100644 Binary files a/assets/maps/corrode_svg_height_attack.json.gz and b/assets/maps/corrode_svg_height_attack.json.gz differ diff --git a/assets/maps/corrode_svg_height_defense.json.gz b/assets/maps/corrode_svg_height_defense.json.gz index 28fc5c5a..3595abb4 100644 Binary files a/assets/maps/corrode_svg_height_defense.json.gz and b/assets/maps/corrode_svg_height_defense.json.gz differ diff --git a/assets/maps/fracture_svg_height_attack.json.gz b/assets/maps/fracture_svg_height_attack.json.gz index 9b689f06..34d49e44 100644 Binary files a/assets/maps/fracture_svg_height_attack.json.gz and b/assets/maps/fracture_svg_height_attack.json.gz differ diff --git a/assets/maps/fracture_svg_height_defense.json.gz b/assets/maps/fracture_svg_height_defense.json.gz index 06def65d..bc474c2f 100644 Binary files a/assets/maps/fracture_svg_height_defense.json.gz and b/assets/maps/fracture_svg_height_defense.json.gz differ diff --git a/assets/maps/haven_svg_height_attack.json.gz b/assets/maps/haven_svg_height_attack.json.gz index ca130cb3..a2b636fe 100644 Binary files a/assets/maps/haven_svg_height_attack.json.gz and b/assets/maps/haven_svg_height_attack.json.gz differ diff --git a/assets/maps/haven_svg_height_defense.json.gz b/assets/maps/haven_svg_height_defense.json.gz index dabaf091..d84f3f0f 100644 Binary files a/assets/maps/haven_svg_height_defense.json.gz and b/assets/maps/haven_svg_height_defense.json.gz differ diff --git a/assets/maps/icebox_svg_height_attack.json.gz b/assets/maps/icebox_svg_height_attack.json.gz index 2c8cfcba..09091ea3 100644 Binary files a/assets/maps/icebox_svg_height_attack.json.gz and b/assets/maps/icebox_svg_height_attack.json.gz differ diff --git a/assets/maps/icebox_svg_height_defense.json.gz b/assets/maps/icebox_svg_height_defense.json.gz index ff7d5d90..6dd031de 100644 Binary files a/assets/maps/icebox_svg_height_defense.json.gz and b/assets/maps/icebox_svg_height_defense.json.gz differ diff --git a/assets/maps/lotus_svg_height_attack.json.gz b/assets/maps/lotus_svg_height_attack.json.gz index 1e4c2cf6..2a2cc2ff 100644 Binary files a/assets/maps/lotus_svg_height_attack.json.gz and b/assets/maps/lotus_svg_height_attack.json.gz differ diff --git a/assets/maps/lotus_svg_height_defense.json.gz b/assets/maps/lotus_svg_height_defense.json.gz index 10b1ad97..ea0b1558 100644 Binary files a/assets/maps/lotus_svg_height_defense.json.gz and b/assets/maps/lotus_svg_height_defense.json.gz differ diff --git a/assets/maps/pearl_svg_height_attack.json.gz b/assets/maps/pearl_svg_height_attack.json.gz index c6217fee..d49030c0 100644 Binary files a/assets/maps/pearl_svg_height_attack.json.gz and b/assets/maps/pearl_svg_height_attack.json.gz differ diff --git a/assets/maps/pearl_svg_height_defense.json.gz b/assets/maps/pearl_svg_height_defense.json.gz index de6d0233..3d025d0d 100644 Binary files a/assets/maps/pearl_svg_height_defense.json.gz and b/assets/maps/pearl_svg_height_defense.json.gz differ diff --git a/assets/maps/split_svg_height_attack.json.gz b/assets/maps/split_svg_height_attack.json.gz index 67f36206..b94e2374 100644 Binary files a/assets/maps/split_svg_height_attack.json.gz and b/assets/maps/split_svg_height_attack.json.gz differ diff --git a/assets/maps/split_svg_height_defense.json.gz b/assets/maps/split_svg_height_defense.json.gz index d680b069..fb6f9427 100644 Binary files a/assets/maps/split_svg_height_defense.json.gz and b/assets/maps/split_svg_height_defense.json.gz differ diff --git a/assets/maps/summit_svg_height_attack.json.gz b/assets/maps/summit_svg_height_attack.json.gz index 9cc2bb81..30f64588 100644 Binary files a/assets/maps/summit_svg_height_attack.json.gz and b/assets/maps/summit_svg_height_attack.json.gz differ diff --git a/assets/maps/summit_svg_height_defense.json.gz b/assets/maps/summit_svg_height_defense.json.gz index 622d6d73..83803526 100644 Binary files a/assets/maps/summit_svg_height_defense.json.gz and b/assets/maps/summit_svg_height_defense.json.gz differ diff --git a/assets/maps/sunset_svg_height_attack.json.gz b/assets/maps/sunset_svg_height_attack.json.gz index 0f42fd4e..1693c46c 100644 Binary files a/assets/maps/sunset_svg_height_attack.json.gz and b/assets/maps/sunset_svg_height_attack.json.gz differ diff --git a/assets/maps/sunset_svg_height_defense.json.gz b/assets/maps/sunset_svg_height_defense.json.gz index a74cf5b3..c42cca61 100644 Binary files a/assets/maps/sunset_svg_height_defense.json.gz and b/assets/maps/sunset_svg_height_defense.json.gz differ diff --git a/docs/vision-model.md b/docs/vision-model.md index 12ef604a..2ec91974 100644 --- a/docs/vision-model.md +++ b/docs/vision-model.md @@ -9,6 +9,132 @@ > `assets/maps/*_svg_height_*.json.gz` models, `tool/check_bundled_wall_heights.dart` > and this document. +## Blocking from VALORANT's minimap lines (2026-10-04) + +The bundled models no longer carry band data measured from the 3D map. Each +wall now takes its heights from VALORANT's minimap vision lines, the 2D line +set the game uses for its own minimap cones. The sections after this one +describe the measured model these walls replace. They still explain the +file format, supports, and how the runtime casts. + +**Source.** `AresWorldSettings.MinimapVisionOccluders` names a +`DataTable` for each map. Each row is one layer. `g` holds the +line count and the layer's threshold in centimetres, `v` holds minimap uv +points, and `l` holds index pairs. The thresholds are absolute heights in +world space. Uv points convert to UE world space through the map's +`XMultiplier`/`XScalarToAdd` and `YMultiplier`/`YScalarToAdd`. They then go to +scene metres as `(X, -Y) / 100` and through the map's `nativeTo{Side}Svg` +affine. + +**Layer rule.** This rule is inferred; the assets do not state it. A viewer +uses layer k when its capsule centre, floor + 0.98 m, lies in +`[th_k, th_k+1)`. The 98 cm capsule half-height is in the assets. A scan of +where each line's obstacle top falls peaks sharply at 0.98–0.99 m; feet and +eye height both score worse. Icarus casts from floor + 1.75 m, so each layer +is stored as an eye band shifted up by 0.77 m. The runtime treats a band as +closed at both ends, but a layer is open at the top. So every band edge sits +1e-6 m below its threshold, and a viewer exactly on a threshold gets the +layer above. Ascent has floors at 5.02 m, under a 6.0 m threshold. + +**Walls are our art.** Riot's lines sit centimetres to a metre off the drawn +walls, which left visible gaps. So the lines decide only how tall a wall is, +and the art decides where it is: +- The wall art is each wall's footprint by its own fill rule, so a hole + drawn inside a wall stays floor. +- Every edge of the wall-art outline, in 0.5-unit pieces, takes the union of + layers of every Riot line running alongside it (|cos| ≥ 0.7, within 2 SVG + units). This keeps both lines on walls that carry a ground line and an + upper one. A short stretch with no parallel line (a bevel, a jog, a + wall's end; at most 4 units, between edges that have one) takes the + layers of the nearest lines, all of them where several are equally near + (Riot often stacks a ground line and an upper line on one spot). A longer + one takes none: a drawn ring or box that + only touches a wall is not part of it, and borrowing the wall's line there + cut cones beside it into slivers. +- Runs of equal layers become one-sided strips, 0.01 thick, inside the art. + The art is closed by 0.03 first, so hairline cracks between strokes don't + leak. Touching strips with the same heights are merged into one outline + each, and those outlines are the model's walls (no `runtimeWalls`). With + a wall per strip, each strip's ends were silhouettes and a cone grew to + about 10,000 points; with strips merged only for casting, stepping an + agent out of a wall searched every strip and took up to 1.6 s. +- A stretch of Riot line with no art beside it (glass, railings, crates the + art doesn't draw) stays as a thin wall on Riot's own line. Where the + stretch was cut because the art beside it takes over, its end is joined + to the nearest art, so no ray slips between the line and the wall. +- Lines Riot lacks are added by hand in the vision-lines review data + (`added` in `.edits.json`). The only one so far is the Lotus defense + platform wall, which the 3D map shows solid from 3.0 to 5.1 m. +- Layers Riot has where the game is open are taken off the same way + (`cleared`: lines lying wholly inside an area lose the listed layers). + The only one so far is the Haven C Garage window, open from the garage + floor (layer 0) as in the 3D map; Dara ruled it open on 2026-09-19 and + again on 2026-10-04. +- The receiver is the floor minus the wall art, so a cone never paints over + a wall. + +`sightlineFloorSupportIds` is dropped. Riot's layers already decide which +floors a viewer sees over. Each model stores the thresholds as +`riotVisionLayers`, and `test/svg_riot_vision_test.dart` checks that every +band edge is one of them. + +**Built by.** `scripts/riot/build_art.py` in the icarus-vision-pipeline +archive, run on the bundled models at commit `0497bec`. The review tool and its line data are in +`tools/vision-lines`. + +**Checked against the 3D map.** This used 240 standing poses per map side and +cast against the extracted geometry. Only the part of each ray that lands on +floor the app draws counts. Rays leaving the map are never drawn, and +counting them made Pearl look worse than it is. A leak is a stretch the +model sees that the 3D map says is hidden; a false shadow is the reverse. +The check is stricter than the game. Riot's minimap ignores props (crates, +poles, low walls) the 3D map has, and the cones beside them look normal. + +| visible floor, both sides | measured model | art + Riot heights | +|---|---|---| +| leaked length | 1,747,007 | 2,313,132 (+32%) | +| false-shadow length | 32,714,287 | 33,816,279 (+3.4%) | + +Pearl (−88% leaked), Summit (−90%), Breeze (−65%), Ascent (−63%), Corrode +(−52%) and Haven (−43%) improve. Abyss, Fracture, Icebox and Sunset leak +about twice as much or more, and Split and Bind about a quarter more, where +Riot's lines are sparse or ignore props. + +Cones stay within 165 fps: on a profile-build drag across every map side, +frame build is at most 3.35 ms at p99 over two runs (budget 6.06 ms) and +no frame's build goes over budget. + +Stepping an agent out of a wall (`standablePointNear`, every frame of a +drag) tests points against every wall and the floor. The floor has every +wall cut out of it, thousands of edges in one ring, and walls run along +whole outlines, so `_Footprint` now files each edge under the 1-unit rows +its height reaches and a test reads one row; the answer is unchanged +(`test/svg_footprint_contains_test.dart`). Over a grid of every 1.37 units, +in a profile build, the step plus choosing the standing level takes at most +2.0–5.6 ms on Lotus, Breeze and Summit, about what the measured model took. + +Walling off everything that is neither floor nor wall was tried. Rays +leaving the map are never drawn, so it changed nothing visible on Pearl. On +Abyss and Icebox it blocked real sightlines across drops and gaps (+54% and ++34% false shadow on visible floor). It was not kept. + +Riot blocks three places the measured model left open: +- the floor past the end of Bind's B container. Dara chose Riot's lines + here (2026-10-04), and the test follows them; +- Breeze Mid's slanted-roof opening. Dara chose Riot's lines here too + (2026-10-04), and the test follows them; +- the Haven C Garage window from the garage floor. Dara kept it open + (2026-10-04), so its lines are cleared for layer 0 and its test checks + the window is open. + +**Rejected.** +- Riot's lines as they come: they leave gaps beside the art. +- Snapping the lines onto the art: tracing closes real openings, and + receiver-edge snapping lands under walls. +- Our measured pieces with Riot heights. +- A measured-height fallback for edges with no Riot line: it adds 4–8% false + shadow. + Before declaring a visibility change complete, apply the acceptance contract (`docs/vision-acceptance-contract.md` in the archive). It defines source accounting, independent expectations, and the evidence required for completion. @@ -513,6 +639,124 @@ the withdrawn void seal and changes nothing else: The reviewed sightline suite from the archive passes on the sealed models. `test/svg_void_window_test.dart` pins the Sunset case. +## Bands checked from where players stand (2026-10-02) + +The ray probe above looks across the ink, half a metre either side. Where the +real face sits further off the ink than that, it measured nothing and left +the piece open. Lotus's defense platform wall (`p7-stroke-3-local-1`, 34 +units long) had no band below 20.75 m, so Chamber standing on the 3 m +platform saw straight through it. Bind's B container outline had no bands at +all. + +The archive's `scripts/truth/` checks the bundled models from the player's +side instead. From every standable spot on an 8-unit grid, about 32,000 on +both sides of all maps, it casts 720 horizontal rays at the runtime eye. Each +ray runs twice: once against the painted walls active at that eye, once +against the 3D scene's solid, non-decor, non-floor faces sliced at the eye. + +A painted piece is solid at an eye when, of the rays from that eye height +that cross it, the scene stops at least 60% within a metre of it (and at +least six). Where a piece the model leaves open is solid, its band is raised +to the height of what those rays hit, cut short at the nearest eye heights +where the scene lets most rays through. Bands only rise. Ids that record a +decision about an opening (review, report, user section, opening, door, +window, sill, jamb, header) are not changed; where the scene disagrees they +are listed in the archive for review. A piece longer than 3 units that the +scene stops only a fifth to three fifths of the rays through is a window in +a longer wall, and is first cut into one-unit pieces (`-truth-cut-N`) so the +solid part can rise without closing the window. + +Result on the bundled models (spots that see through a painted piece the +scene says is solid, then rays): 11,054 to 5,905 spots and 337,158 to +104,440 rays, with false shadows (painted walls blocking where the scene is +open) up 0.7%. 1,397 pieces rose and 128 were cut into 1,910. Most of what +the check still reports is not a data error. Of the remaining leak rays, +68% cross pieces the scene leaves open at that eye, where a prop or frame +near the piece stopped a few; 20% cross short pieces that really are partly +open, such as railings. `test/svg_truth_bands_test.dart` (now `svg_riot_vision_test.dart`) pinned the Lotus wall +and the Bind container. + +Dara ruled on the 119 recorded-decision pieces from in-game renders on +2026-10-03. 51 became solid at the measured heights. Most are the Abyss +atrium wall's `user-section` strips. They were never a decision: the +2026-09-12 screenshot pass (`scripts/review_reported_sightlines.py`) +measured `p7-stroke-0` in half-unit strips against four source objects that +did not include the atrium wall itself (object 4912), so every strip only it +covered measured empty and stayed open, leaving centimetre holes along a +solid wall. The 2026-09-14 pass added 4912 for eleven strips and missed the +rest. All now block at 3.96 to 8.0 m. The other closures are the two +Fracture corridor openings and Haven defense's +`p3-stroke-10-gameplay-opening-0`, Mid Window, which had lost its sill; the +attack side's `p3-stroke-12-gameplay-opening-0` gets the same measured sill. The Haven, Icebox and Pearl +door and corridor openings stay open. The rulings are in the archive as +`scripts/data/truth-bands-dara-review-2026-10-03.json`. + +### Holes in walls (2026-10-03) + +The Abyss strips were one case of a general fault: earlier passes measured +walls in short strips against chosen objects, and a strip that missed them +stayed open. The archive's `scripts/truth/notch.py` finds every hole +directly. A hole is a piece no longer than a metre that is open over some +height while touching pieces on both sides of it are solid there, up to +40 m over its floor. Each is checked against every solid, non-decor face +within half a metre of the piece. Where the scene is solid over at least +80% of the hole, the hole is filled; where it is open, the hole stays and +is listed for review. A piece whose top is lower than its neighbours' is a +hole only when the scene is solid up to their height. Dara's rulings are +never touched. Across both sides of all maps it found 5,343 holes and +filled 4,096. The other 463 are open in the scene but are single pieces, +a few tens of centimetres wide, between solid walls; renders from the +standing spots that see them show mostly solid wall, its face more than +half a metre off the ink. A real window or doorway spans several pieces, +so these are filled too. + +### Reviewed openings put back (2026-10-03) + +The archive's acceptance suite (`scripts/truth/accept_at.sh`) holds the +sightlines earlier reviews pinned. On the #240 models 7 of its tests fail +(Split's crane, which #238 reverses, and fixture hashes); after the passes +above, 26 did. Astra cast every new failure against the complete 3D scene +(`scripts/truth/ray3d.py`) rather than a chosen object list. Six were clear +in the scene: the hole fill had closed a gap at Corrode's 4801 pieces. +Others contradicted a ruling or a recorded decision. `restore_reviewed.py` +puts these pieces back to their #240 bands, uncutting any `-truth-cut-N` +pieces: +- the two Corrode pieces; +- Haven's C Garage window walls, which Dara opened from the garage floor + on 2026-09-19; +- Haven defense `p3-stroke-9`, whose raised band stopped a Mid sightline + where the scene is clear; +- Icebox's zipline and ramp markings, which are symbols, not walls; +- every piece named see-through. + +Seven failures remain, and each is deliberate. Four are the Haven Mid +Window sill Dara ruled solid. The other three, Icebox's front window jamb +and the boost-step box, pin sightlines the scene blocks. +`test/svg_truth_bands_test.dart` (now `svg_riot_vision_test.dart`) pinned the garage window. On the 3D check's +current standing spots (`poses-240.json`), the restore takes leaks from +5,565 spots and 100,106 rays to 5,679 and 104,153, mostly where the +garage ruling opens the window. + +A rebuild of each drawn wall as two or three constant-height segments was +tried and not used (`scripts/truth/segment_walls.py` records why). It +removed 35k leak rays but added 160k false-shadow rays, because it closed +pieces the scene shows mostly open. It also shut reviewed openings that the +scene confirms are clear. One band set per piece cannot hold a window in +part of a piece, and a vote across neighbours makes that worse. + +### Merged runtime outlines (2026-10-03) + +Each model now carries `runtimeWalls`: the touching pieces that share a +floor, bands and unknown-height flag, merged offline into one outline by +the archive's `scripts/truth/merge_runtime.py`. Cones are cast against these +outlines, with 1.6 to 4 times fewer points than the pieces. The pieces remain +the model. The loader checks that every piece is covered once and that +every member of an outline has the same heights. Where an outline does not +cover a piece's own edges (a bow tie, a sliver), those edges come along +under the piece's heights (`heightsOf`). Merging seals cracks narrower than +0.06 SVG units (under 2 cm) between pieces of one wall and changes nothing +else; the cone areas it was checked on differ by at most 0.074%. + ## Drag performance on Windows (2026-09-19) Dara's bar: dragging an agent with a cone must feel instant on Windows. diff --git a/lib/view_cone/svg_height_visibility.dart b/lib/view_cone/svg_height_visibility.dart index b7d2fd73..1dfeebac 100644 --- a/lib/view_cone/svg_height_visibility.dart +++ b/lib/view_cone/svg_height_visibility.dart @@ -412,14 +412,15 @@ class SvgHeightVisibility { // a seam with the next piece of the same wall, or face the unplayable // side of a building, and stepping across either lands back in ink. // Failing that, step across the nearest edge and try again from there. - target = _steppedAcross(current, wall.rings, 0.02, + target = _steppedAcross(current, wall.rings, _inkClearance, inside: wall.contains, reach: maxDistance - (current - point).distance, accept: (p) => (p - point).distance <= maxDistance && _blockingWallAt(p) == null && receiverContains(p)) ?? - _steppedAcross(current, wall.rings, 0.02, inside: wall.contains); + _steppedAcross(current, wall.rings, _inkClearance, + inside: wall.contains); } else { target = _pulledIn(current, 0.02); } @@ -430,13 +431,22 @@ class SvgHeightVisibility { return null; } + /// How close to blocking ink still counts as in it. Two strokes drawn a + /// couple of centimetres apart leave a slit no agent stands in; a cone + /// from inside one is a hairline running down it. + static const _inkMargin = 0.05; + + /// How far past the ink a nudged agent stands: clear of [_inkMargin]. + static const _inkClearance = 0.06; + SvgHeightWall? _blockingWallAt(Offset point) { // A wall that does not block a standing eye (a kerb, a floor mark) is // not something an agent stands inside of. - final floor = ground?.heightAt(point); - final eye = (floor ?? 0) + defaultCameraHeightMeters; + double? eye; for (final wall in walls) { - if (wall.contains(point) && wall.blocks(eye)) return wall; + if (!wall._contains(point, _inkMargin)) continue; + eye ??= (ground?.heightAt(point) ?? 0) + defaultCameraHeightMeters; + if (wall.blocks(eye)) return wall; } return null; } @@ -1367,27 +1377,67 @@ class _Footprint { late final Rect bounds; bool contains(Offset point) => _contains(point, 0); - bool _contains(Offset point, double boundaryTolerance) { - if (point.dx < bounds.left - boundaryTolerance || - point.dx > bounds.right + boundaryTolerance || - point.dy < bounds.top - boundaryTolerance || - point.dy > bounds.bottom + boundaryTolerance) return false; - var winding = 0; - for (final ring in rings) { + /// The largest boundary tolerance [_contains] is asked for. + static const _maxTolerance = 0.05; + + /// Rows are a unit tall, or taller for a footprint so tall that unit rows + /// would number more than this. + static const _maxRows = 4096; + late final double _rowHeight = + math.max(1.0, (bounds.height + 2 * _maxTolerance) / _maxRows); + + /// Every edge, as its ring and first point's index, filed under each + /// [_rowHeight] row its height, grown by [_maxTolerance], reaches. Only + /// edges spanning a point's height add to its winding, and only edges that + /// near can hold it on their boundary, so a test reads one row. A floor + /// with every wall cut out of it is thousands of edges in one ring. + late final List _rows = () { + final rows = List.generate(_rowCount, (_) => []); + for (var r = 0; r < rings.length; r++) { + final ring = rings[r]; for (var i = 0; i < ring.length; i++) { final a = ring[i], b = ring[(i + 1) % ring.length]; - final side = _cross(b - a, point - a); - if ((side == 0 || - boundaryTolerance > 0 && - side.abs() <= boundaryTolerance * (b - a).distance) && - point.dx >= math.min(a.dx, b.dx) - boundaryTolerance && - point.dx <= math.max(a.dx, b.dx) + boundaryTolerance && - point.dy >= math.min(a.dy, b.dy) - boundaryTolerance && - point.dy <= math.max(a.dy, b.dy) + boundaryTolerance) return true; - if (a.dy <= point.dy && b.dy > point.dy && side > 0) winding++; - if (a.dy > point.dy && b.dy <= point.dy && side < 0) winding--; + final first = _row(math.min(a.dy, b.dy) - _maxTolerance); + final last = _row(math.max(a.dy, b.dy) + _maxTolerance); + for (var row = first; row <= last; row++) + rows[row] + ..add(r) + ..add(i); } } + return [for (final row in rows) Int32List.fromList(row)]; + }(); + + late final int _rowCount = + (bounds.height + 2 * _maxTolerance) ~/ _rowHeight + 1; + + int _row(double y) => ((y - bounds.top + _maxTolerance) / _rowHeight) + .floor() + .clamp(0, _rowCount - 1); + + bool _contains(Offset point, double boundaryTolerance) { + assert(boundaryTolerance <= _maxTolerance); + // Written so a NaN coordinate fails it, as it failed every edge before. + if (!(point.dx >= bounds.left - boundaryTolerance && + point.dx <= bounds.right + boundaryTolerance && + point.dy >= bounds.top - boundaryTolerance && + point.dy <= bounds.bottom + boundaryTolerance)) return false; + var winding = 0; + final row = _rows[_row(point.dy)]; + for (var e = 0; e < row.length; e += 2) { + final ring = rings[row[e]], i = row[e + 1]; + final a = ring[i], b = ring[(i + 1) % ring.length]; + final side = _cross(b - a, point - a); + if ((side == 0 || + boundaryTolerance > 0 && + side.abs() <= boundaryTolerance * (b - a).distance) && + point.dx >= math.min(a.dx, b.dx) - boundaryTolerance && + point.dx <= math.max(a.dx, b.dx) + boundaryTolerance && + point.dy >= math.min(a.dy, b.dy) - boundaryTolerance && + point.dy <= math.max(a.dy, b.dy) + boundaryTolerance) return true; + if (a.dy <= point.dy && b.dy > point.dy && side > 0) winding++; + if (a.dy > point.dy && b.dy <= point.dy && side < 0) winding--; + } return evenOdd ? winding.abs().isOdd : winding != 0; } } diff --git a/test/bundled_map_models_test.dart b/test/bundled_map_models_test.dart index 26e6a9ad..f97176ca 100644 --- a/test/bundled_map_models_test.dart +++ b/test/bundled_map_models_test.dart @@ -3,65 +3,66 @@ import 'dart:io'; import 'package:crypto/crypto.dart'; import 'package:flutter_test/flutter_test.dart'; -/// The bundled SVG-height models encode every sightline ruling Dara made -/// during the 2026-09 review (see docs/vision-model.md). The per-ruling -/// acceptance tests and the pipeline that produced these files live in the -/// icarus-vision-pipeline archive. Here the models are pinned byte for byte: +/// The bundled SVG-height models are Icarus's drawn walls carrying the +/// heights of VALORANT's minimap vision lines (see docs/vision-model.md). The +/// builder and the vision-line review data live in the icarus-vision-pipeline +/// archive (scripts/riot, tools/vision-lines). Here the models are pinned +/// byte for byte: /// a change to any of them is a change to what players see and must come /// with a deliberate update to this table. const _modelChecksums = { 'abyss_svg_height_attack.json.gz': - '4f7b09791e08b2df1ef3ee5242654e7cdbca1c35f2636c6b58f6a794d949c2d8', + 'd2584571cbfa55707b282e001c6d623d55afdbe6bcd4ef49544432db5be9ccbc', 'abyss_svg_height_defense.json.gz': - '1a450b6c369eb879d5f785c5637daca8780e122c4609020cd9b95b6ce0912a3e', + '9a96967a96c7838090ac090dd9abc330a872e17caec252397b49b4e4d1c2f76c', 'ascent_svg_height_attack.json.gz': - 'ef96b0367e0b8949c2529b56e0abbfc47e0631e3b6ea832e9b667226a63caf13', + '00530c60076b564b3ac5020d983de60d1554fdc3e12f97d137b49f9932ba42cb', 'ascent_svg_height_defense.json.gz': - '38c9c8a40ac53bfe246660d3ce605e3d05d96ffe2904343b5973ff8a067cb753', + 'e25313890e098d5bef86fa094f512483c82e0fb166074f80d3081dece976a2a7', 'bind_svg_height_attack.json.gz': - 'f2892c1aef0f62c0cc3c8516f13b03275f4679fe7db7b1be0140d918edb09b5a', + 'ae27a16a23fbdf5b44ca050c16b1f89a03bea4113efad1da1d95e3c5b08111ba', 'bind_svg_height_defense.json.gz': - '236d891cdc37488cc076b9301e509d9a140751f180673ce71a46be62a7f74d6d', + '8c6d2611c88518e900fc59c5bc7ba0f1fea15c1e0491ad0b4618a64286515af3', 'breeze_svg_height_attack.json.gz': - '66e63a0e31ce5083db29be07f5e3cd7bd45b5c53e851c1b5eab297b7db4ec17a', + 'afa3dbbe0a741ad3bb9df699deed3fbdfe07764be5136ab07735f456786b996b', 'breeze_svg_height_defense.json.gz': - 'd40b25298334eb2c2074d20d1c3acc1ca36fe074dc1bae02d3c2c3f0d7a2adc9', + 'c744d6bb946abd1ed233a6cc0bb214dc5ff42317941128f0b6edb4b953d9f6ee', 'corrode_svg_height_attack.json.gz': - 'e3cc0f04f17c364c38720da2a1c37a69ed905b507867d85b61c1f9004869287d', + '467fc710f8912b81ae31403fc144cf94730891e87b9bd57dd07f5f1076b0415d', 'corrode_svg_height_defense.json.gz': - '43dbd72bc7e4bc7250b1f6468edee7385b48c5255d9dd33fad286f67016c6fa3', + '32b41d8de237a8788b65ec655339310b5f14f4baa37c2468c7a82f1cb0acf136', 'fracture_svg_height_attack.json.gz': - '17bea88c009b01a619b3a9c0ec73e4042d0bd2b38490eb53e12b9c3fb24915ce', + '181776c0bfc042d3901367f9e1db8276f2c5d8f57b4fd5cc575b3cdb815c7b9b', 'fracture_svg_height_defense.json.gz': - '77ce5636607ec03393e1a11d6454586781439d5d6bd37ab2193f6e5b7e7e224f', + '04f40b1d132e21a783106fadb3ec604309318da0491f664121a0d122b6202f52', 'haven_svg_height_attack.json.gz': - 'f3ef26501e6fb9a3c48307e8a8b28583e6a731cf30dc047ab7fef86d1795632a', + '43529cc5731df622df66c7d05205fb149bb36ae0d14e7940a1e83af0a1535ecf', 'haven_svg_height_defense.json.gz': - '54b9f0eafc867940e7556949f2cfbe886d6e446da72b038f6d891112dd5890fd', + 'b4fb1b072f0de7c8d78141ea1227befe60c4dfc61234fdb6e6c54585ec0c24ee', 'icebox_svg_height_attack.json.gz': - '0b3aa659f5223a378f97573582243a0202be8d398fc74fa818994cd09d78e3e4', + '5755af829f1ef568fa8c8cc49256d431e987b8dc020f227be83d8644dfa9f22d', 'icebox_svg_height_defense.json.gz': - 'ad5ae3d58600316ebb5bde272444c476d1297e349dbd59e3df696ea958bffcc5', + 'e1feb443b83915f362db96df9644274f3f8abb9636b39b359c4b14e3004373aa', 'lotus_svg_height_attack.json.gz': - 'f41a05363c9dfcaf577cbcbe470c1436cbc8bd5c22c891ba85625e325a666d1b', + 'e0246d2298948c97cb1b14a7e3a78c37639364781746d85246f3326d88e646e7', 'lotus_svg_height_defense.json.gz': - 'bad95da0f2ded6ef3a9c8d3ae4ca8b61fedeab1df59eeed40838fe83ec26f593', + 'ba349690b50fa9101bd78534e1ed70e36e0b4e3976276257d167243d6e000d15', 'pearl_svg_height_attack.json.gz': - '6202255d92c611a2b020089c501136d651f6e4fecf99156509332f514daea6c6', + '9b1e1751375c4f350221632a1218a6d82b468dc99cd3a73c4c9e444d46624011', 'pearl_svg_height_defense.json.gz': - '91b47d86f95a7a99b866e8a13b1fcff9af47c68c259f47d9be2588255ea0bd1d', + '64c06641fe30e66cdbee3e4e5cbbeb230ac817a797be1c0c5255f5f6a52d2121', 'split_svg_height_attack.json.gz': - 'd24243709ddd4f9322f70c16c4d449e0557a90cd00dca038d6196f3911a7a2d7', + 'a532f2815c1d570727063c6e07bd776228ea745b015990f1411a437244d49287', 'split_svg_height_defense.json.gz': - 'e1975791d570b7d5e570853eda8d00fbc3a3cc33c6ebabbad1ee04247b6f7b76', + 'd2c33e89e929919a07522b562825cc4d529ebf0a5d82e751df1c99b471e5e0c0', 'summit_svg_height_attack.json.gz': - '24334581de53f9a9a7bcb35ce85425e95f141dbe77f6f576249bdaa23a83d723', + '30e0894932abde8134fa477edb41885cde316637ca548d2bc701fd191e8d70b0', 'summit_svg_height_defense.json.gz': - 'c8462c9dc9d0dc3285363ee4f3b0376f1035dfcb7774dbec18a20fb4f73c0960', + 'd9499a88a76fb73f31d818a1c28e200687a662df71827aa133f0bb56b908b876', 'sunset_svg_height_attack.json.gz': - 'ae8783c980c648a5c09ffc472378184f8076e69e062552d51958fac39a3915dd', + '012f38f95f49df79c81896685e899de4408147fb367e178954d34c6c4e3842f4', 'sunset_svg_height_defense.json.gz': - '41028e8d49f11ae17d980f52897f44b540fab2c62394adf9bee24e2b64adf8ff', + '86f9c66c4670ba1dc7859e2a71f0ac7c58929e63d900d68e720cb304747780b6', }; void main() { @@ -77,7 +78,8 @@ void main() { if (actual != entry.value) mismatches.add('${entry.key}: $actual'); } expect(mismatches, isEmpty, - reason: 'A reviewed model changed. Re-run the sightline review in the ' + reason: + 'A reviewed model changed. Rebuild it with scripts/riot/build_art.py in the ' 'icarus-vision-pipeline archive before updating its checksum.'); }); } diff --git a/test/svg_footprint_contains_test.dart b/test/svg_footprint_contains_test.dart new file mode 100644 index 00000000..8f0bded2 --- /dev/null +++ b/test/svg_footprint_contains_test.dart @@ -0,0 +1,62 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:math' as math; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/view_cone/svg_height_visibility.dart'; + +/// Every edge of every ring, as containment read before edges were filed by +/// row: the answer the filed lookup must still give. +bool _everyEdge(List> rings, bool evenOdd, Offset point) { + var winding = 0; + for (final ring in rings) { + for (var i = 0; i < ring.length; i++) { + final a = ring[i], b = ring[(i + 1) % ring.length]; + final side = + (b.dx - a.dx) * (point.dy - a.dy) - (b.dy - a.dy) * (point.dx - a.dx); + if (side == 0 && + point.dx >= math.min(a.dx, b.dx) && + point.dx <= math.max(a.dx, b.dx) && + point.dy >= math.min(a.dy, b.dy) && + point.dy <= math.max(a.dy, b.dy)) return true; + if (a.dy <= point.dy && b.dy > point.dy && side > 0) winding++; + if (a.dy > point.dy && b.dy <= point.dy && side < 0) winding--; + } + } + return evenOdd ? winding.abs().isOdd : winding != 0; +} + +void main() { + test('walls and floor answer containment as reading every edge did', () { + final model = SvgHeightVisibility.fromJson(jsonDecode(utf8.decode( + gzip.decode(File('assets/maps/lotus_svg_height_defense.json.gz') + .readAsBytesSync()))) as Map); + final random = math.Random(7); + final points = [ + for (var i = 0; i < 4000; i++) + Offset(random.nextDouble() * 500, random.nextDouble() * 500), + // Points on vertices and edges, where rows meet and boundaries count. + for (final wall in model.walls.take(40)) + for (final ring in wall.rings) + for (var i = 0; i < ring.length; i += 7) ...[ + ring[i], + Offset.lerp(ring[i], ring[(i + 1) % ring.length], 0.5)!, + ], + ]; + final footprints = [ + for (final wall in model.walls) + (wall.id, wall.rings, wall.evenOdd, wall.contains), + for (final (i, receiver) in model.receivers.indexed) + ('receiver $i', receiver.rings, receiver.evenOdd, receiver.contains), + ]; + var inside = 0; + for (final point in points) { + for (final (id, rings, evenOdd, contains) in footprints) { + final expected = _everyEdge(rings, evenOdd, point); + expect(contains(point), expected, reason: '$id at $point'); + if (expected) inside++; + } + } + expect(inside, greaterThan(1000)); + }); +} diff --git a/test/svg_riot_vision_test.dart b/test/svg_riot_vision_test.dart new file mode 100644 index 00000000..87f1a9c8 --- /dev/null +++ b/test/svg_riot_vision_test.dart @@ -0,0 +1,162 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:math' as math; +import 'dart:ui' show Path; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/view_cone/svg_height_visibility.dart'; + +/// The bundled walls are Icarus's drawn walls carrying the heights of +/// VALORANT's own minimap vision lines (see docs/vision-model.md, "Blocking +/// from VALORANT's minimap lines"). +Map _json(String name, String side) => jsonDecode(utf8.decode( + gzip.decode(File('assets/maps/${name}_svg_height_$side.json.gz') + .readAsBytesSync()))) as Map; + +SvgHeightVisibility _model(String name, String side) => + SvgHeightVisibility.fromJson(_json(name, side)); + +/// Whether an agent standing at [from] on its default level sees [target]. +bool _sees(SvgHeightVisibility model, Offset from, Offset target) { + final support = model.automaticSupportAt(from); + final toward = target - from; + final cone = model.cone( + origin: from, + directionRadians: math.atan2(toward.dy, toward.dx), + range: 120, + apertureRadians: math.pi / 6, + supportId: support?.id, + ); + final visible = + cone.visibilityPath ?? (Path()..addPolygon(cone.polygon, true)); + return visible.contains(target); +} + +const _maps = [ + 'abyss', 'ascent', 'bind', 'breeze', 'corrode', 'fracture', 'haven', // + 'icebox', 'lotus', 'pearl', 'split', 'summit', 'sunset', +]; + +void main() { + test('every wall blocks a run of Riot layers and nothing else', () { + // A wall in layer k blocks a viewer whose capsule centre (floor + 0.98 m) + // stands in [th_k, th_k+1); stored as eye heights (capsule + 0.77 m) + // over a floor 100 m down. Every band edge is one of those, a hair + // below it so a viewer exactly on a threshold gets the layer above. + for (final name in _maps) { + for (final side in ['attack', 'defense']) { + final json = _json(name, side); + final layers = (json['riotVisionLayers'] as List).cast(); + final edges = {for (final t in layers) (t + 0.77 + 100).toDouble()}; + for (final wall in (json['walls'] as List).cast()) { + expect(wall['floorElevationMeters'], -100.0); + expect(wall['bands'], isNotEmpty, reason: '${wall['id']}'); + for (final band in (wall['bands'] as List).cast()) { + for (final edge in band) { + if (edge == null || edge == 0) continue; + expect(edges.any((e) => ((edge as num) - e).abs() < 1e-3), isTrue, + reason: '$name $side ${wall['id']}: $edge is no layer edge'); + } + } + } + } + } + }); + + test('a viewer standing exactly on a threshold gets the layer above', () { + // Ascent has floors at 5.02 m: a capsule centre of exactly 6.0 m, the + // threshold between two layers. Riot's layers are open at the top. + final json = _json('ascent', 'attack'); + final model = SvgHeightVisibility.fromJson(json); + final layers = (json['riotVisionLayers'] as List).cast(); + var checked = 0; + for (final wall in model.walls) { + for (final t in layers.skip(1)) { + final eye = t - 0.98 + 1.75; + final top = wall.bands.any((b) => (b.top - (t + 100.77)).abs() < 1e-3); + final bottom = + wall.bands.any((b) => (b.bottom - (t + 100.77)).abs() < 1e-3); + if (top) { + expect(wall.blocks(eye), isFalse, reason: '${wall.id} at $t'); + expect(wall.blocks(eye - 0.001), isTrue, reason: '${wall.id} at $t'); + checked++; + } + if (bottom) { + expect(wall.blocks(eye), isTrue, reason: '${wall.id} at $t'); + expect(wall.blocks(eye - 0.001), isFalse, reason: '${wall.id} at $t'); + checked++; + } + } + } + expect(checked, greaterThan(0)); + }); + + test('a hole in a wall stays floor', () { + // Haven's p5-stroke-10 is drawn even-odd with floor inside it. + final model = _model('haven', 'attack'); + expect(model.receiverContains(const Offset(198.4425, 184.726)), isTrue); + }); + + test('a drawn ring by a wall does not slice a cone into slivers', () { + // Split's ring against the B wall: Riot has no line for it, so it does + // not block. Its curve near the wall used to borrow the wall's line and + // cut a cone from just inside it into slivers. + final model = _model('split', 'attack'); + final origin = model.standablePointNear(const Offset(76.757, 246.138))!; + const direction = 2.32; + final cone = model.cone( + origin: origin, + directionRadians: direction, + range: 120, + apertureRadians: math.pi / 3, + supportId: model.standingSupportAt(origin)?.id, + ); + final visible = + cone.visibilityPath ?? (Path()..addPolygon(cone.polygon, true)); + for (var a = direction - 0.45; a <= direction + 0.45; a += 0.05) { + final target = origin + Offset(math.cos(a), math.sin(a)) * 12; + expect(visible.contains(target), isTrue, reason: 'angle $a'); + } + }); + + test('the Lotus defense platform wall stops a standing cone', () { + // Dara's replay, round 2 at 0:11: Chamber on the 3 m platform looked + // through this wall. Riot has no line along it; ours (an added line in + // the vision-lines review data) blocks layers 1 to 3. + final model = _model('lotus', 'defense'); + const platform = Offset(425, 325.4); + expect(model.automaticSupportAt(platform)?.surfaceElevationAt(platform), + closeTo(3.0, 0.05)); + expect(_sees(model, platform, const Offset(425, 346)), isFalse); + expect(_sees(model, platform, const Offset(425, 335)), isTrue, + reason: 'the platform in front of the wall stays lit'); + }); + + test('Bind B container blocks a cone from the ground beside it', () { + final model = _model('bind', 'attack'); + expect( + _sees(model, const Offset(107.5, 158.5), const Offset(71.59, 147.35)), + isFalse); + }); + + test('Breeze Mid blocks the slanted-roof opening from the perch', () { + // The 3D scene clears a ray from the 10 m perch through the opening + // to a standing eye on the crate across Mid; Riot's lines block it. + final model = _model('breeze', 'attack'); + const perch = Offset(160, 190); + expect(model.automaticSupportAt(perch)?.surfaceElevationAt(perch), + closeTo(10.0, 0.1)); + expect(_sees(model, perch, const Offset(213.49, 183.84)), isFalse); + }); + + test('the Haven garage window stays open from the garage floor', () { + // Dara (2026-09-19, again 2026-10-04): the C Garage window is open + // from the floor, as in the 3D map. VALORANT's minimap lines close it + // there, so haven.edits.json clears them (`cleared`). + final model = _model('haven', 'attack'); + expect( + _sees(model, const Offset(131.651, 246.013), + const Offset(137.17, 202.36)), + isTrue); + }); +} diff --git a/test/svg_standable_point_test.dart b/test/svg_standable_point_test.dart index 2597500f..aeaed654 100644 --- a/test/svg_standable_point_test.dart +++ b/test/svg_standable_point_test.dart @@ -25,6 +25,20 @@ void main() { model.standablePointNear(const Offset(20, 20)), const Offset(20, 20)); }); + test('a point within the ink margin below a wall is pushed out', () { + // 54.96 is 0.02 below the wall, inside the 0.05 margin that counts as + // ink, and in the next unit row down from the wall's bottom edge. + final low = SvgHeightVisibility.fromJson(data( + [ + wall('pillar', [rectangle(45, 45, 55, 54.94)]) + ], + receiver: [_floor(rectangle(0, 0, 100, 100))], + )); + final moved = low.standablePointNear(const Offset(50, 54.96)); + expect(moved, isNotNull); + expect(moved!.dy, greaterThan(54.99)); + }); + test('a point inside wall ink is pushed just outside the wall', () { final moved = model.standablePointNear(const Offset(46, 50)); expect(moved, isNotNull); @@ -76,4 +90,18 @@ void main() { expect((moved! - start).distance, lessThanOrEqualTo(2.5)); expect(boxed.walls.where((w) => w.contains(moved)), isEmpty); }); + + test('a slit between two walls is not somewhere to stand', () { + // Two walls drawn 0.02 apart, as Breeze attack has them at y 107.6. + final slit = SvgHeightVisibility.fromJson(data( + [ + wall('north', [rectangle(0, 9, 100, 10)]), + wall('south', [rectangle(0, 10.02, 100, 11)]), + ], + receiver: [_floor(rectangle(0, 0, 100, 100))], + )); + final moved = slit.standablePointNear(const Offset(50, 10.01))!; + expect(moved.dy < 9 || moved.dy > 11, isTrue, + reason: 'stepped out to open floor, not left in the slit: $moved'); + }); } diff --git a/test/svg_void_window_test.dart b/test/svg_void_window_test.dart index f8fb34ab..a21b40f8 100644 --- a/test/svg_void_window_test.dart +++ b/test/svg_void_window_test.dart @@ -43,15 +43,4 @@ void main() { closeTo(5.0, 0.05)); expect(_sees(model, roof, beyond), isFalse); }); - - test('Breeze Mid keeps its slanted-roof opening', () { - // From the 11.8 m perch, a ray through the opening reaches a standing - // eye on the crate across Mid; the 3D scene clears it all the way. - final model = _model('breeze'); - const perch = Offset(160, 190); - const crate = Offset(213.49, 183.84); - expect(model.automaticSupportAt(perch)?.surfaceElevationAt(perch), - closeTo(10.0, 0.1)); - expect(_sees(model, perch, crate), isTrue); - }); }