From a8fc6d51db88e8060837bd454602ed8eff923447 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Wed, 19 Aug 2026 16:48:24 -0400 Subject: [PATCH] feature: smoke volumes, sightlines and drift simulation --- README.md | 129 +++++++ cmd/server/drift.go | 299 +++++++++++++++ cmd/server/drift_test.go | 274 ++++++++++++++ cmd/server/http.go | 4 + cmd/server/main.go | 18 +- cmd/server/nades.go | 186 ++++++++++ cmd/server/nades_test.go | 333 +++++++++++++++++ internal/geometry/bvh.go | 19 +- internal/geometry/cache_test.go | 85 +++++ internal/geometry/geometry_test.go | 59 +++ internal/geometry/load.go | 101 +++++- internal/geometry/mesh.go | 53 +++ internal/parser/grenades.go | 3 + internal/parser/nades.go | 432 ++++++++++++++++++++++ internal/parser/nades_test.go | 348 ++++++++++++++++++ internal/parser/oneway.go | 354 ++++++++++++++++++ internal/parser/oneway_test.go | 276 ++++++++++++++ internal/parser/parser.go | 14 + internal/parser/positions_test.go | 216 +++++++++++ internal/parser/shots.go | 116 +++++- internal/parser/types.go | 40 ++- internal/simulate/drift.go | 424 ++++++++++++++++++++++ internal/simulate/drift_test.go | 452 +++++++++++++++++++++++ internal/simulate/flight.go | 559 +++++++++++++++++++++++++++++ internal/simulate/flight_test.go | 277 ++++++++++++++ internal/simulate/helpers_test.go | 326 +++++++++++++++++ 26 files changed, 5371 insertions(+), 26 deletions(-) create mode 100644 cmd/server/drift.go create mode 100644 cmd/server/drift_test.go create mode 100644 cmd/server/nades.go create mode 100644 cmd/server/nades_test.go create mode 100644 internal/parser/nades.go create mode 100644 internal/parser/nades_test.go create mode 100644 internal/parser/oneway.go create mode 100644 internal/parser/oneway_test.go create mode 100644 internal/parser/positions_test.go create mode 100644 internal/simulate/drift.go create mode 100644 internal/simulate/drift_test.go create mode 100644 internal/simulate/flight.go create mode 100644 internal/simulate/flight_test.go create mode 100644 internal/simulate/helpers_test.go diff --git a/README.md b/README.md index 004277b..1a0e5cf 100644 --- a/README.md +++ b/README.md @@ -5,3 +5,132 @@ The demo parser is a small HTTP + CLI service that wraps [markus-wa/demoinfocs-golang](https://github.com/markus-wa/demoinfocs-golang) to extract playback metadata, events, and player stats from a CS2 `.dem` file. Please visit [5Stack](https://docs.5stack.gg) for more documentation. + +### Endpoints + +| Endpoint | Body | Answers | +| --- | --- | --- | +| `POST /parse` | `{"demo_url": "..."}` | parsed `Result` JSON | +| `POST /parse-file` | multipart, `demo` part | parsed `Result` JSON | +| `POST /smoke-volume` | `{"map","x","y","z"}` | the bloom at a point | +| `POST /sightlines` | map + smoke(s) + eye-position pairs | what the smoke blocks | +| `POST /oneway` | map + smoke(s) + player-position pairs | asymmetric visibility | +| `POST /drift` | map + two mesh revisions + lineups | which lineups a map update moved | + +The last four answer questions about a **map**, not a demo, so they work for a +lineup nobody has ever thrown. They need the map's collision mesh and return +`404` when none is published for it. Coordinates are raw CS2 source units. + +`/smoke-volume` returns the same voxel grid the playback blob carries +(`ox/oy/oz`, `vs`, `dx/dy/dz`, `den`), so one decoder serves both, plus `cells` +and `radius`. A point that resolves inside geometry returns `422`. + +`/sightlines` takes `at` (a detonation point), `smoke`, or `smokes` — a cloud is +either `{"at": {...}}` or `{"volume": {...}}`, where the volume is one +`/smoke-volume` handed straight back to skip the flood. Each pair returns +`blocked`, `blocked_by` (`world` / `smoke`), `depth` (optical depth in cell +widths of full density), `transmittance` (`e^-depth`) and `world_blocked`. +`threshold` defaults to `3.0` — about 5% of the target's contrast surviving — +and is a request field because it is a judgement, not a measurement. + +`/oneway` takes player positions (feet by default, `"positions": "eyes"` +otherwise) and tests both standing and crouched eye heights in both directions, +treating each player as a body rather than a point. It reports `one_way`, +`favors`, `cause`, `confidence`, `contested`, the `best` stance pairing and all +four pairings, along with `caveats` describing what the model does not know. + +### `POST /drift` — map-patch drift detection + +When Valve ships a map update, every stored lineup is re-flown against the old +collision mesh and the new one, and the two endpoints are compared. + +**The output is a differential and nothing else.** The grenade simulator behind +it is deterministic and self-consistent but *not* fitted to CS2: an absolute +landing point out of it is wrong by an unknown amount. It is sound here only +because the same model error appears on both sides and cancels. The position +field is called `comparison_point` for that reason, and **no coordinate from +this endpoint may ever be shown to a player as where their nade lands.** + +```jsonc +POST /drift +{ + "map": "de_mirage", + "from": "17595823-4", // mesh revision before the patch + "to": "17595823-5", // after it; "" means the revision this process is pinned to + "lineups": [ + { + "id": "1f4c…", + "nade_type": "Smoke", // Smoke | HE | Flash | Molotov | Decoy + "initial_position": {"x": -2300, "y": 0, "z": -64}, // both optional; a lineup + "initial_velocity": {"x": 500, "y": -80, "z": 200} // missing either is unsimulatable + } + ], + "stream": false, // true → NDJSON, required above 2000 lineups + "unchanged_radius": 8, // optional threshold overrides + "major_radius": 64, + "constants": {"gravity": 320} // optional physics overrides, applied to BOTH sides +} +``` + +A **mesh revision** is a jsDelivr tag (`17595823-5`), an `owner/repo@tag`, or an +`http(s)` base for a mirror. `from` and `to` come back **resolved**, so a blank +one is legible in the report later. + +Each lineup gets one of four verdicts: + +| verdict | meaning | +| --- | --- | +| `unchanged` | both meshes end the flight in the same place, within `unchanged_radius` | +| `moved` | both resolve, and the endpoint shifted — `severity` is `minor` or `major` | +| `broken` | it resolved on the old mesh and does not on the new one: `inside_geometry`, `start_sealed`, `out_of_world`, or never comes to rest | +| `unsimulatable` | no recorded seed, an unknown grenade, or a flight that fails on **both** meshes — which says nothing about the map | + +```jsonc +{ + "map": "de_mirage", + "from": "…", "to": "…", + "constants": { … }, // echoed, so a report can be reproduced + "thresholds": {"unchanged": 8, "major": 64}, + "summary": {"lineups": 900, "unchanged": 848, "moved": 9, "broken": 2, "unsimulatable": 41, "max_distance": 213.4}, + "results": [ + { + "index": 0, // request order, for matching when ids repeat + "id": "1f4c…", + "verdict": "moved", + "severity": "minor", + "reason": "landing moved 21.7 units", + "from": {"comparison_point": {"x":…,"y":…,"z":…}, "resolved": true, "stop": "rest", + "bounces": 3, "flight_seconds": 3.61, "steps": 462}, + "to": { … }, + "distance": 21.7, "distance_xy": 21.6, "distance_z": 1.9 // absent unless BOTH sides resolved + } + ], + "caveats": ["…"] // ships with the payload; surface it wherever the numbers are shown +} +``` + +`"stream": true` returns `application/x-ndjson`: one `{"type":"header"}` line, +one `{"type":"result"}` line per lineup **in request order**, then +`{"type":"summary"}`. The status code is sent before the first result, so a run +that fails part way through ends with a `{"type":"error"}` line — a consumer +that does not check for one will read a truncated run as a clean one. + +**Thresholds are judgements, not measurements.** `unchanged` is 8 units: two +re-exports of an unchanged map still round vertices differently, and a bounce +grazing that seam lands a few units off. `major` is 64 units — a smoke's radius +is 144 and a player is 32 wide, so past 64 a cloud no longer covers the same +gap. Both are request fields. + +**Batch sizing.** A lineup costs two flights: ~2 ms on one core, ~0.4 ms across +eight (measured on de_mirage, 138k triangles). 500-1000 per request is the +comfortable size; 2000 is the cap for a single JSON body and 10000 the hard cap +with `stream`. Drift is the only endpoint that holds **two** meshes at once — +21 MiB for the de_mirage pair, 136 MiB for de_anubis — so it is serialized to +one request at a time. Raise `DRIFT_CONCURRENCY` only on a pod dedicated to it, +and `DRIFT_WORKERS` (default `min(NumCPU, 8)`) to change per-request +parallelism; neither changes the answer. + +Mesh loading is process-wide, concurrency-safe and LRU-bounded; raise +`MAP_MESH_CACHE` (default 2) on a deployment serving lineups across many maps. +Drift needs at least 2 or it refetches a mesh per request, and 4 is a better +number on a pod that also serves the other endpoints. diff --git a/cmd/server/drift.go b/cmd/server/drift.go new file mode 100644 index 0000000..7eff5c2 --- /dev/null +++ b/cmd/server/drift.go @@ -0,0 +1,299 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "os" + "runtime" + "strconv" + "strings" + "sync" + + "github.com/5stackgg/demo-parser/internal/geometry" + "github.com/5stackgg/demo-parser/internal/simulate" +) + +// POST /drift — map-patch drift detection. +// +// Given a map, two mesh revisions and a batch of stored lineups, re-fly every +// lineup against both meshes and report what moved. The other map endpoints +// answer questions about one mesh; this one is the only place two are resident +// at once, which is what shapes the limits below. + +// maxDriftBody caps the request body. A lineup is ~150 bytes of JSON, so this +// comfortably holds the MaxLineups cap with room for a full constants block. +const maxDriftBody = 16 << 20 + +// driftConcurrency bounds how many drift requests run at once, and it is +// deliberately 1 by default. +// +// A drift request holds TWO meshes for its whole life — 11-68 MiB each once the +// BVH is built — and it holds them past any cache eviction, since it keeps the +// pointers. Two concurrent requests on different maps is four meshes and a +// quarter of a gigabyte on top of whatever demo this pod is parsing. Drift runs +// when a map updates, which is rarely and in bulk, so serializing costs nothing +// that matters. Raise DRIFT_CONCURRENCY on a pod dedicated to it. +func driftConcurrency() int { + if n, ok := envInt("DRIFT_CONCURRENCY"); ok && n > 0 { + return n + } + return 1 +} + +// driftWorkers is how many flights one request runs in parallel. Flights are +// independent and each is deterministic, so this changes throughput and nothing +// about the answer. Capped because the BVH walk is memory-latency bound and +// stops scaling well before it saturates a big machine — and because this pod +// still has demos to parse. +func driftWorkers() int { + if n, ok := envInt("DRIFT_WORKERS"); ok && n > 0 { + return n + } + return min(runtime.NumCPU(), 8) +} + +func envInt(key string) (int, bool) { + v, ok := os.LookupEnv(key) + if !ok { + return 0, false + } + n, err := strconv.Atoi(strings.TrimSpace(v)) + if err != nil { + return 0, false + } + return n, true +} + +var driftSlots = make(chan struct{}, max(1, driftConcurrency())) + +// The mesh LRU has to hold both revisions or every request re-downloads the +// one it evicted. Warned once rather than per request, and not enforced: a +// bound of 1 still produces correct answers, just slowly. +var warnCacheOnce sync.Once + +func warnIfCacheTooSmall() { + warnCacheOnce.Do(func() { + if n := geometry.MaxCachedMeshes(); n < 2 { + log.Printf("[drift] MAP_MESH_CACHE is %d; drift holds two meshes at once, so every request will refetch one. Set it to 2 or more.", n) + } + }) +} + +func handleDrift(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req simulate.DriftRequest + dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxDriftBody)) + if err := dec.Decode(&req); err != nil { + http.Error(w, fmt.Sprintf("bad request: %v", err), http.StatusBadRequest) + return + } + if strings.TrimSpace(req.Map) == "" { + http.Error(w, "map is required", http.StatusBadRequest) + return + } + if len(req.Lineups) == 0 { + http.Error(w, "lineups must not be empty", http.StatusBadRequest) + return + } + if len(req.Lineups) > simulate.MaxLineups { + http.Error(w, fmt.Sprintf("too many lineups: %d (max %d)", len(req.Lineups), simulate.MaxLineups), + http.StatusRequestEntityTooLarge) + return + } + if !req.Stream && len(req.Lineups) > simulate.MaxBufferedLineups { + http.Error(w, fmt.Sprintf( + "%d lineups is more than the %d that fit in one JSON body: set \"stream\": true for NDJSON, or send smaller batches", + len(req.Lineups), simulate.MaxBufferedLineups), http.StatusRequestEntityTooLarge) + return + } + + fromRef, err := geometry.ResolveMeshRevision(req.From) + if err != nil { + http.Error(w, fmt.Sprintf("from: %v", err), http.StatusBadRequest) + return + } + toRef, err := geometry.ResolveMeshRevision(req.To) + if err != nil { + http.Error(w, fmt.Sprintf("to: %v", err), http.StatusBadRequest) + return + } + warnIfCacheTooSmall() + + fromMesh, err := meshRevision(req.Map, req.From) + if err != nil { + driftMeshError(w, "from", req.Map, req.From, err) + return + } + toMesh, err := meshRevision(req.Map, req.To) + if err != nil { + driftMeshError(w, "to", req.Map, req.To, err) + return + } + + if err := acquireDrift(r.Context()); err != nil { + http.Error(w, "server busy", http.StatusServiceUnavailable) + return + } + defer releaseDrift() + + // Echo the resolved revisions rather than what was sent, so an empty "from" + // (meaning "whatever this process is pinned to") is legible in the report + // months later. + req.From, req.To = fromRef, toRef + + if req.Stream { + streamDrift(w, fromMesh, toMesh, req) + return + } + res, err := simulate.Drift(fromMesh, toMesh, req, simulate.DriftOptions{Workers: driftWorkers()}) + if err != nil { + // Both meshes are already resolved and the batch is already sized, so + // anything left is the request describing something that cannot be + // run — a constant or a threshold out of range. + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + log.Printf("[drift] %s %s -> %s: %d lineups, %d moved, %d broken, %d unsimulatable", + req.Map, req.From, req.To, res.Summary.Lineups, res.Summary.Moved, res.Summary.Broken, res.Summary.Unsimulatable) + writeJSON(w, res) +} + +// meshRevision loads one revision of a map's mesh, mapping "no .tri published" +// onto the same ErrNoMesh the other endpoints use. +func meshRevision(mapName, revision string) (*geometry.Mesh, error) { + mesh, err := geometry.LoadRevision(mapName, revision) + if err != nil { + return nil, errUpstream{fmt.Errorf("load mesh for %s: %w", mapName, err)} + } + if mesh == nil { + return nil, simulate.ErrNoMesh + } + return mesh, nil +} + +// driftMeshError names which side failed. "no mesh at revision X" and "no mesh +// at revision Y" are very different problems for the caller — the first is a +// bad old tag, the second means the new mesh set has not been published yet. +func driftMeshError(w http.ResponseWriter, side, mapName, revision string, err error) { + var upstream errUpstream + switch { + case errors.Is(err, simulate.ErrNoMesh): + if revision == "" { + revision = "the pinned revision" + } + http.Error(w, fmt.Sprintf("%s: no collision mesh for map %q at %s", side, mapName, revision), + http.StatusNotFound) + case errors.As(err, &upstream): + http.Error(w, fmt.Sprintf("%s: %v", side, err), http.StatusBadGateway) + default: + http.Error(w, fmt.Sprintf("%s: %v", side, err), http.StatusBadRequest) + } +} + +func acquireDrift(ctx context.Context) error { + select { + case driftSlots <- struct{}{}: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func releaseDrift() { + <-driftSlots +} + +// streamDrift writes the report as NDJSON: one header line, one line per +// lineup, one summary line. A batch of thousands is answered without ever +// holding more than a chunk of results in memory, and the caller can start +// filing bug reports before the run finishes. +// +// The status line is already sent by the time the first result is written, so a +// failure part way through cannot be a status code — it is a final line with +// type "error", and a consumer that does not check for one will silently treat +// a truncated run as a clean one. +func streamDrift(w http.ResponseWriter, fromMesh, toMesh *geometry.Mesh, req simulate.DriftRequest) { + w.Header().Set("Content-Type", "application/x-ndjson") + w.WriteHeader(http.StatusOK) + flusher, _ := w.(http.Flusher) + enc := json.NewEncoder(w) + + consts := req.Constants.Apply(simulate.DefaultConstants()) + if err := enc.Encode(driftStreamHeader{ + Type: "header", + Map: req.Map, + From: req.From, + To: req.To, + Lineups: len(req.Lineups), + Constants: consts, + Thresholds: req.Thresholds(), + Caveats: simulate.DriftCaveats(), + }); err != nil { + return + } + + emit := func(batch []simulate.LineupDrift) error { + for i := range batch { + if err := enc.Encode(driftStreamResult{Type: "result", LineupDrift: batch[i]}); err != nil { + return err + } + } + if flusher != nil { + flusher.Flush() + } + return nil + } + + res, err := simulate.Drift(fromMesh, toMesh, req, simulate.DriftOptions{ + Workers: driftWorkers(), + Emit: emit, + }) + if err != nil { + _ = enc.Encode(driftStreamError{Type: "error", Error: err.Error()}) + if flusher != nil { + flusher.Flush() + } + return + } + _ = enc.Encode(driftStreamSummary{Type: "summary", Summary: res.Summary}) + if flusher != nil { + flusher.Flush() + } + log.Printf("[drift] %s %s -> %s (streamed): %d lineups, %d moved, %d broken, %d unsimulatable", + req.Map, req.From, req.To, res.Summary.Lineups, res.Summary.Moved, res.Summary.Broken, res.Summary.Unsimulatable) +} + +// The NDJSON line shapes. Every line carries "type", so one decoder handles the +// whole stream. +type driftStreamHeader struct { + Type string `json:"type"` + Map string `json:"map"` + From string `json:"from"` + To string `json:"to"` + Lineups int `json:"lineups"` + Constants simulate.Constants `json:"constants"` + Thresholds simulate.Thresholds `json:"thresholds"` + Caveats []string `json:"caveats"` +} + +type driftStreamResult struct { + Type string `json:"type"` + simulate.LineupDrift +} + +type driftStreamSummary struct { + Type string `json:"type"` + Summary simulate.DriftSummary `json:"summary"` +} + +type driftStreamError struct { + Type string `json:"type"` + Error string `json:"error"` +} diff --git a/cmd/server/drift_test.go b/cmd/server/drift_test.go new file mode 100644 index 0000000..d50e03b --- /dev/null +++ b/cmd/server/drift_test.go @@ -0,0 +1,274 @@ +package main + +import ( + "bufio" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/5stackgg/demo-parser/internal/simulate" +) + +// meshRevisions serves the local replay-map-meshes clone n times over, each on +// its own base URL. Two of them are two mesh revisions as far as the loader is +// concerned, built independently from identical bytes — which is how the +// endpoint gets exercised end to end without inventing a fake .tri. +func meshRevisions(t *testing.T, n int) []string { + t.Helper() + dir := meshFixtureDir(t) + if dir == "" { + t.Skip("no replay-map-meshes clone found above the working directory; set MAP_MESH_FIXTURES") + } + // Both revisions have to stay resident for the whole request or every + // lineup pays for a refetch. + t.Setenv("MAP_MESH_CACHE", "4") + refs := make([]string, 0, n) + for i := 0; i < n; i++ { + srv := httptest.NewServer(http.FileServer(http.Dir(dir))) + t.Cleanup(srv.Close) + refs = append(refs, srv.URL) + } + return refs +} + +func mirageLineups(n int) []simulate.LineupSeed { + out := make([]simulate.LineupSeed, 0, n) + types := []string{"Smoke", "HE", "Flash", "Molotov"} + for i := 0; i < n; i++ { + out = append(out, simulate.LineupSeed{ + ID: string(rune('a'+i%26)) + "-lineup", + NadeType: types[i%len(types)], + InitialPosition: &simulate.Point{X: -2300, Y: 0, Z: -64}, + InitialVelocity: &simulate.Point{X: 500, Y: float64(i%40)*10 - 200, Z: 200}, + }) + } + return out +} + +// The end-to-end shape of the contract the API codes against, and the property +// it rests on: the same mesh on both sides reports no drift at all. +func TestDriftEndpointOnARealMap(t *testing.T) { + refs := meshRevisions(t, 2) + w := post(t, handleDrift, simulate.DriftRequest{ + Map: "de_mirage", + From: refs[0], + To: refs[1], + Lineups: mirageLineups(16), + }) + if w.Code != http.StatusOK { + t.Fatalf("status %d: %s", w.Code, w.Body.String()) + } + + // Decoded as a bare map first: these key names are the contract. + var raw map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil { + t.Fatalf("decode: %v", err) + } + for _, key := range []string{"map", "from", "to", "constants", "thresholds", "summary", "results", "caveats"} { + if _, ok := raw[key]; !ok { + t.Fatalf("response is missing %q: %v", key, raw) + } + } + results, _ := raw["results"].([]any) + if len(results) != 16 { + t.Fatalf("got %d results, want 16", len(results)) + } + first, _ := results[0].(map[string]any) + for _, key := range []string{"index", "id", "verdict", "from", "to"} { + if _, ok := first[key]; !ok { + t.Fatalf("result is missing %q: %v", key, first) + } + } + // The one field name that has to survive every refactor: it is the whole + // warning label on this endpoint. + side, _ := first["to"].(map[string]any) + if _, ok := side["comparison_point"]; !ok { + t.Fatalf("an outcome must report a comparison_point, not a landing: %v", side) + } + + var res simulate.DriftResponse + if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil { + t.Fatalf("decode: %v", err) + } + if res.Summary.Moved != 0 || res.Summary.Broken != 0 { + t.Fatalf("identical geometry must not report drift: %+v", res.Summary) + } + if res.Summary.Unchanged == 0 { + t.Fatal("nothing resolved; the test is not exercising the simulator") + } + if len(res.Caveats) == 0 { + t.Fatal("the response must carry its caveats") + } + if res.From == "" || res.To == "" { + t.Fatal("the response must echo the revisions it compared") + } +} + +// An empty revision means "whatever this process is pinned to", and the +// response says which that was rather than echoing the blank. +func TestDriftEndpointResolvesTheDefaultRevision(t *testing.T) { + refs := meshRevisions(t, 1) + t.Setenv("MAP_MESH_CDN", refs[0]) + w := post(t, handleDrift, simulate.DriftRequest{ + Map: "de_mirage", + Lineups: mirageLineups(2), + }) + if w.Code != http.StatusOK { + t.Fatalf("status %d: %s", w.Code, w.Body.String()) + } + var res simulate.DriftResponse + if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil { + t.Fatalf("decode: %v", err) + } + if res.From != refs[0] || res.To != refs[0] { + t.Fatalf("an empty revision should resolve to the pinned base, got from=%q to=%q", res.From, res.To) + } +} + +func TestDriftEndpointStreamsNDJSON(t *testing.T) { + refs := meshRevisions(t, 2) + w := post(t, handleDrift, simulate.DriftRequest{ + Map: "de_mirage", + From: refs[0], + To: refs[1], + Lineups: mirageLineups(12), + Stream: true, + }) + if w.Code != http.StatusOK { + t.Fatalf("status %d: %s", w.Code, w.Body.String()) + } + if ct := w.Header().Get("Content-Type"); ct != "application/x-ndjson" { + t.Fatalf("content type %q", ct) + } + + var ( + kinds []string + results int + summary simulate.DriftSummary + ) + scan := bufio.NewScanner(strings.NewReader(w.Body.String())) + for scan.Scan() { + line := scan.Bytes() + var envelope struct { + Type string `json:"type"` + Index int `json:"index"` + Verdict string `json:"verdict"` + Summary simulate.DriftSummary `json:"summary"` + Error string `json:"error"` + } + if err := json.Unmarshal(line, &envelope); err != nil { + t.Fatalf("line is not JSON: %s", line) + } + kinds = append(kinds, envelope.Type) + switch envelope.Type { + case "result": + if envelope.Index != results { + t.Fatalf("result %d arrived out of order (index %d)", results, envelope.Index) + } + if envelope.Verdict == "" { + t.Fatalf("result %d has no verdict: %s", results, line) + } + results++ + case "summary": + summary = envelope.Summary + case "error": + t.Fatalf("stream reported an error: %s", envelope.Error) + } + } + if err := scan.Err(); err != nil { + t.Fatalf("scan: %v", err) + } + if kinds[0] != "header" || kinds[len(kinds)-1] != "summary" { + t.Fatalf("stream should open with a header and close with a summary, got %v", kinds) + } + if results != 12 { + t.Fatalf("streamed %d results, want 12", results) + } + if summary.Lineups != 12 || summary.Moved != 0 || summary.Broken != 0 { + t.Fatalf("identical geometry must not report drift: %+v", summary) + } +} + +// A batch too big for one JSON body is refused with the fix in the message, +// rather than being answered with a response nothing can hold. +func TestDriftEndpointRefusesAnUnbufferableBatch(t *testing.T) { + // No mesh is fetched: the size check runs before anything is loaded, which + // is the point of putting it first. + w := post(t, handleDrift, simulate.DriftRequest{ + Map: "de_mirage", + Lineups: make([]simulate.LineupSeed, simulate.MaxBufferedLineups+1), + }) + if w.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("status %d: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "stream") { + t.Fatalf("the error should point at streaming: %s", w.Body.String()) + } + + w = post(t, handleDrift, simulate.DriftRequest{ + Map: "de_mirage", + Stream: true, + Lineups: make([]simulate.LineupSeed, simulate.MaxLineups+1), + }) + if w.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("over the hard cap: status %d: %s", w.Code, w.Body.String()) + } +} + +func TestDriftEndpointRejectsBadRequests(t *testing.T) { + cases := []struct { + name string + req simulate.DriftRequest + want int + }{ + {"no map", simulate.DriftRequest{Lineups: mirageLineups(1)}, http.StatusBadRequest}, + {"no lineups", simulate.DriftRequest{Map: "de_mirage"}, http.StatusBadRequest}, + { + "revision that walks out of the pinned path", + simulate.DriftRequest{Map: "de_mirage", From: "../../../etc", Lineups: mirageLineups(1)}, + http.StatusBadRequest, + }, + { + "revision with a slash in the tag", + simulate.DriftRequest{Map: "de_mirage", To: "repo@tag/../..", Lineups: mirageLineups(1)}, + http.StatusBadRequest, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + w := post(t, handleDrift, tc.req) + if w.Code != tc.want { + t.Fatalf("status %d (want %d): %s", w.Code, tc.want, w.Body.String()) + } + }) + } +} + +// A map with no published .tri at one of the two revisions is a 404 that says +// WHICH side is missing: a bad old tag and an unpublished new mesh set are +// different problems with different fixes. +func TestDriftEndpointNamesTheMissingSide(t *testing.T) { + refs := meshRevisions(t, 2) + w := post(t, handleDrift, simulate.DriftRequest{ + Map: "de_nonexistent", + From: refs[0], + To: refs[1], + Lineups: mirageLineups(1), + }) + if w.Code != http.StatusNotFound { + t.Fatalf("status %d: %s", w.Code, w.Body.String()) + } + if !strings.HasPrefix(w.Body.String(), "from:") { + t.Fatalf("the error should name the side that is missing: %s", w.Body.String()) + } +} + +func TestDriftEndpointRejectsNonPost(t *testing.T) { + w := httptest.NewRecorder() + handleDrift(w, httptest.NewRequest(http.MethodGet, "/drift", nil)) + if w.Code != http.StatusMethodNotAllowed { + t.Fatalf("status %d", w.Code) + } +} diff --git a/cmd/server/http.go b/cmd/server/http.go index 1759e51..817870c 100644 --- a/cmd/server/http.go +++ b/cmd/server/http.go @@ -34,6 +34,10 @@ func runServer() { mux.HandleFunc("/health", handleHealth) mux.HandleFunc("/parse", handleParse) mux.HandleFunc("/parse-file", handleParseFile) + mux.HandleFunc("/smoke-volume", handleSmokeVolume) + mux.HandleFunc("/sightlines", handleSightlines) + mux.HandleFunc("/oneway", handleOneway) + mux.HandleFunc("/drift", handleDrift) srv := &http.Server{ Addr: addr, diff --git a/cmd/server/main.go b/cmd/server/main.go index e9cebee..b647c63 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -4,11 +4,19 @@ // Result JSON to stdout. Exits non-zero on // parse error. // -// demo-parser server HTTP service with two endpoints: -// POST /parse — JSON {demo_url} body; -// server fetches the demo. -// POST /parse-file — multipart upload with a -// "demo" file part. +// demo-parser server HTTP service: +// POST /parse — JSON {demo_url} body; +// server fetches the demo. +// POST /parse-file — multipart upload with a +// "demo" file part. +// POST /smoke-volume — the bloom at a point on a map. +// POST /sightlines — what a smoke blocks. +// POST /oneway — asymmetric visibility through +// a smoke. +// POST /drift — which lineups a map update moved. +// +// The last four answer questions about a map rather than a demo, for the +// lineup library; see cmd/server/nades.go and cmd/server/drift.go. // // Default (no arg) is `server` for backwards compatibility with old // docker images that ENTRYPOINT'd the binary directly. diff --git a/cmd/server/nades.go b/cmd/server/nades.go new file mode 100644 index 0000000..01fc14d --- /dev/null +++ b/cmd/server/nades.go @@ -0,0 +1,186 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "runtime" + "strings" + + "github.com/5stackgg/demo-parser/internal/geometry" + "github.com/5stackgg/demo-parser/internal/parser" +) + +// The lineup endpoints: given a map and a point, what shape does the smoke +// take, and what does it block. None of them touch a demo — they run the same +// geometry the parser runs, against a mesh loaded by map name, so a lineup +// nobody has ever thrown can be answered the same way a played one is. + +// maxGeometryBody caps a request body. The largest legitimate one is a few +// hundred sightline pairs plus a supplied volume, which is tens of KB. +const maxGeometryBody = 1 << 20 + +// geometrySlots bounds how many requests build or walk a volume at once. A +// build is thousands of raycasts against a mesh of a hundred thousand +// triangles, and this process also parses demos; without a bound a burst of +// previews from one browser would starve them. +var geometrySlots = make(chan struct{}, max(2, runtime.NumCPU())) + +func acquireGeometry(ctx context.Context) error { + select { + case geometrySlots <- struct{}{}: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func releaseGeometry() { + <-geometrySlots +} + +// meshFor resolves a map name to its collision mesh. geometry.Load is +// process-wide, concurrency-safe and LRU-bounded (MAP_MESH_CACHE), so many maps +// can pass through this service without it holding all of them: a mesh is 2-19 +// MB on the wire and several times that once the BVH is built. +func meshFor(mapName string) (*geometry.Mesh, error) { + if strings.TrimSpace(mapName) == "" { + return nil, errors.New("map is required") + } + mesh, err := geometry.Load(mapName) + if err != nil { + return nil, errUpstream{fmt.Errorf("load mesh for %s: %w", mapName, err)} + } + if mesh == nil { + return nil, parser.ErrNoMesh + } + return mesh, nil +} + +// errUpstream marks a failure that is not the caller's doing — the mesh CDN +// being unreachable — so it is not reported back as a bad request. +type errUpstream struct{ err error } + +func (e errUpstream) Error() string { return e.err.Error() } +func (e errUpstream) Unwrap() error { return e.err } + +// geometryError maps a failure onto a status. Anything unrecognised is the +// caller's request being wrong rather than the server failing, since these +// handlers do no I/O of their own beyond the mesh fetch. +func geometryError(w http.ResponseWriter, mapName string, err error) { + var upstream errUpstream + switch { + case errors.Is(err, parser.ErrNoMesh): + http.Error(w, fmt.Sprintf("no collision mesh published for map %q", mapName), http.StatusNotFound) + case errors.Is(err, parser.ErrSmokeSealed): + http.Error(w, "smoke point is sealed inside geometry: no free space to bloom into", http.StatusUnprocessableEntity) + case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded): + http.Error(w, "server busy", http.StatusServiceUnavailable) + case errors.As(err, &upstream): + http.Error(w, err.Error(), http.StatusBadGateway) + default: + http.Error(w, err.Error(), http.StatusBadRequest) + } +} + +func decodeGeometryRequest(w http.ResponseWriter, r *http.Request, into any) bool { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return false + } + dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxGeometryBody)) + if err := dec.Decode(into); err != nil { + http.Error(w, fmt.Sprintf("bad request: %v", err), http.StatusBadRequest) + return false + } + return true +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(v); err != nil { + log.Printf("[geometry] response encode: %v", err) + } +} + +// handleSmokeVolume returns the real bloom at a point: the free space a smoke +// would fill there, as the same voxel grid the playback blob carries. +func handleSmokeVolume(w http.ResponseWriter, r *http.Request) { + var req parser.SmokeVolumeRequest + if !decodeGeometryRequest(w, r, &req) { + return + } + mesh, err := meshFor(req.Map) + if err != nil { + geometryError(w, req.Map, err) + return + } + if err := acquireGeometry(r.Context()); err != nil { + geometryError(w, req.Map, err) + return + } + defer releaseGeometry() + + res, err := parser.SmokeVolume(mesh, req) + if err != nil { + geometryError(w, req.Map, err) + return + } + writeJSON(w, res) +} + +// handleSightlines answers, for a batch of eye-to-eye lines, whether the smoke +// (or the map) blocks them and by how much. +func handleSightlines(w http.ResponseWriter, r *http.Request) { + var req parser.SightlineRequest + if !decodeGeometryRequest(w, r, &req) { + return + } + mesh, err := meshFor(req.Map) + if err != nil { + geometryError(w, req.Map, err) + return + } + if err := acquireGeometry(r.Context()); err != nil { + geometryError(w, req.Map, err) + return + } + defer releaseGeometry() + + res, err := parser.Sightlines(mesh, req) + if err != nil { + geometryError(w, req.Map, err) + return + } + writeJSON(w, res) +} + +// handleOneway reports asymmetric visibility — one side sees through, the other +// does not — across standing and crouched eye heights. +func handleOneway(w http.ResponseWriter, r *http.Request) { + var req parser.OneWayRequest + if !decodeGeometryRequest(w, r, &req) { + return + } + mesh, err := meshFor(req.Map) + if err != nil { + geometryError(w, req.Map, err) + return + } + if err := acquireGeometry(r.Context()); err != nil { + geometryError(w, req.Map, err) + return + } + defer releaseGeometry() + + res, err := parser.OneWay(mesh, req) + if err != nil { + geometryError(w, req.Map, err) + return + } + writeJSON(w, res) +} diff --git a/cmd/server/nades_test.go b/cmd/server/nades_test.go new file mode 100644 index 0000000..4cb9c01 --- /dev/null +++ b/cmd/server/nades_test.go @@ -0,0 +1,333 @@ +package main + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "math" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/5stackgg/demo-parser/internal/geometry" + "github.com/5stackgg/demo-parser/internal/parser" + "github.com/golang/geo/r3" +) + +// A spot on Mirage's A site: open ground with a wall about a hundred units to +// the -X side and the floor just below. Real geometry rather than a synthetic +// box, so the numbers below are what the shipped mesh actually produces. +var mirageSpot = parser.Point{X: -2300, Y: 0, Z: -128} + +// meshFixtureDir locates the replay-map-meshes clone checked out above this +// repo, so the endpoints run against a real .tri rather than a fixture shaped +// like one. Empty when the clone is absent — the meshes are tens of MB and are +// not vendored here, so those tests skip. +func meshFixtureDir(t *testing.T) string { + t.Helper() + if dir := os.Getenv("MAP_MESH_FIXTURES"); dir != "" { + return dir + } + wd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + for d := wd; ; { + candidate := filepath.Join(d, "replay-map-meshes") + if _, err := os.Stat(filepath.Join(candidate, "de_mirage.tri")); err == nil { + return candidate + } + parent := filepath.Dir(d) + if parent == d { + return "" + } + d = parent + } +} + +// serveLocalMeshes points the mesh loader at that clone. +func serveLocalMeshes(t *testing.T) { + t.Helper() + dir := meshFixtureDir(t) + if dir == "" { + t.Skip("no replay-map-meshes clone found above the working directory; set MAP_MESH_FIXTURES") + } + srv := httptest.NewServer(http.FileServer(http.Dir(dir))) + t.Cleanup(srv.Close) + t.Setenv("MAP_MESH_CDN", srv.URL) +} + +func post(t *testing.T, h http.HandlerFunc, body any) *httptest.ResponseRecorder { + t.Helper() + raw, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal request: %v", err) + } + w := httptest.NewRecorder() + h(w, httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(raw))) + return w +} + +// floorUnder is the world height of the surface below a point, by raycast +// against the same mesh the endpoint used. +func floorUnder(t *testing.T, at parser.Point) float64 { + t.Helper() + mesh, err := geometry.Load("de_mirage") + if err != nil || mesh == nil { + t.Fatalf("load mesh: %v", err) + } + d, ok := mesh.RayHitDist(r3.Vector{X: at.X, Y: at.Y, Z: at.Z}, r3.Vector{Z: -1}) + if !ok { + t.Fatal("no floor under the test point") + } + return at.Z - d +} + +func TestSmokeVolumeEndpointOnARealMap(t *testing.T) { + serveLocalMeshes(t) + w := post(t, handleSmokeVolume, parser.SmokeVolumeRequest{ + Map: "de_mirage", X: mirageSpot.X, Y: mirageSpot.Y, Z: mirageSpot.Z, + }) + if w.Code != http.StatusOK { + t.Fatalf("status %d: %s", w.Code, w.Body.String()) + } + + // Decoded as a bare map first: these key names are the contract the web + // renderer and the API are coding against. + var raw map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil { + t.Fatalf("decode: %v", err) + } + for _, key := range []string{"map", "ox", "oy", "oz", "vs", "dx", "dy", "dz", "den", "cells", "radius"} { + if _, ok := raw[key]; !ok { + t.Fatalf("response is missing %q: %v", key, raw) + } + } + + var res parser.SmokeVolumeResponse + if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil { + t.Fatalf("decode: %v", err) + } + if res.Cells < 500 { + t.Fatalf("only %d cells on open ground; expected a full cloud", res.Cells) + } + if res.VoxelSize <= 0 || res.DimX <= 0 || res.DimY <= 0 || res.DimZ <= 0 { + t.Fatalf("degenerate grid: %+v", res.EventSmokeVolume) + } + packed, err := base64.StdEncoding.DecodeString(res.Density) + if err != nil { + t.Fatalf("density is not base64: %v", err) + } + total := res.DimX * res.DimY * res.DimZ + if len(packed) != (total+1)/2 { + t.Fatalf("density is %d bytes, want %d for %d cells", len(packed), (total+1)/2, total) + } + + // The map is what shapes the cloud, and the two things it must do here are + // stop it at the floor and stop it at the wall. An unshaped sphere would be + // the full 19 cells on every axis. + full := 2*int(math.Ceil(144/float64(res.VoxelSize))) + 1 + if res.DimZ >= full { + t.Fatalf("the floor should have clipped the cloud vertically: %d of %d cells", res.DimZ, full) + } + floor := floorUnder(t, mirageSpot) + if lowest := float64(res.OriginZ) + float64(res.VoxelSize)/2; lowest < floor { + t.Fatalf("cloud sank through the floor: lowest cell centre %.1f, floor %.1f", lowest, floor) + } +} + +func TestSmokeVolumeEndpoint404sAnUnknownMap(t *testing.T) { + serveLocalMeshes(t) + w := post(t, handleSmokeVolume, parser.SmokeVolumeRequest{Map: "de_not_a_map"}) + if w.Code != http.StatusNotFound { + t.Fatalf("status %d, want 404: %s", w.Code, w.Body.String()) + } +} + +// The status a caller sees decides whether they retry, fix their request, or +// tell the user the map is unsupported, so the mapping is pinned. +func TestGeometryErrorsMapToStatuses(t *testing.T) { + cases := []struct { + name string + err error + want int + }{ + {"unsupported map", parser.ErrNoMesh, http.StatusNotFound}, + {"point inside geometry", parser.ErrSmokeSealed, http.StatusUnprocessableEntity}, + {"mesh cdn down", errUpstream{errors.New("load mesh for de_mirage: timeout")}, http.StatusBadGateway}, + {"client went away", context.Canceled, http.StatusServiceUnavailable}, + {"anything else", errors.New("pairs must not be empty"), http.StatusBadRequest}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + w := httptest.NewRecorder() + geometryError(w, "de_mirage", tc.err) + if w.Code != tc.want { + t.Fatalf("status %d, want %d (%s)", w.Code, tc.want, w.Body.String()) + } + }) + } +} + +func TestGeometryEndpointsRejectBadRequests(t *testing.T) { + serveLocalMeshes(t) + handlers := map[string]http.HandlerFunc{ + "smoke-volume": handleSmokeVolume, + "sightlines": handleSightlines, + "oneway": handleOneway, + } + for name, h := range handlers { + t.Run(name+" method", func(t *testing.T) { + w := httptest.NewRecorder() + h(w, httptest.NewRequest(http.MethodGet, "/", nil)) + if w.Code != http.StatusMethodNotAllowed { + t.Fatalf("status %d, want 405", w.Code) + } + }) + t.Run(name+" body", func(t *testing.T) { + w := httptest.NewRecorder() + h(w, httptest.NewRequest(http.MethodPost, "/", bytes.NewReader([]byte("{")))) + if w.Code != http.StatusBadRequest { + t.Fatalf("status %d, want 400", w.Code) + } + }) + t.Run(name+" no map", func(t *testing.T) { + w := post(t, h, map[string]any{"pairs": []any{}}) + if w.Code != http.StatusBadRequest { + t.Fatalf("status %d, want 400: %s", w.Code, w.Body.String()) + } + }) + } +} + +func TestSightlinesEndpointOnARealMap(t *testing.T) { + serveLocalMeshes(t) + at := mirageSpot + // Along Y so the lines stay in the open part of the site: through the + // cloud, over the top of it where it thins out, and well clear of it. + line := func(dz, dy float64) parser.SightlinePair { + return parser.SightlinePair{ + From: parser.Point{X: at.X, Y: at.Y - 220 + dy, Z: at.Z + dz}, + To: parser.Point{X: at.X, Y: at.Y + 220 + dy, Z: at.Z + dz}, + } + } + w := post(t, handleSightlines, parser.SightlineRequest{ + Map: "de_mirage", + At: &at, + Pairs: []parser.SightlinePair{line(0, 0), line(90, 0), { + // Well clear of the cloud and still in the open part of the site. + From: parser.Point{X: at.X, Y: at.Y - 500, Z: at.Z}, + To: parser.Point{X: at.X, Y: at.Y - 300, Z: at.Z}, + }}, + }) + if w.Code != http.StatusOK { + t.Fatalf("status %d: %s", w.Code, w.Body.String()) + } + var res parser.SightlineResponse + if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil { + t.Fatalf("decode: %v", err) + } + if res.Threshold != parser.DefaultBlockThreshold { + t.Fatalf("threshold = %v, want the default", res.Threshold) + } + if len(res.Smokes) != 1 || res.Smokes[0].Sealed { + t.Fatalf("expected one flooded cloud: %+v", res.Smokes) + } + + core, over, away := res.Results[0], res.Results[1], res.Results[2] + if !core.Blocked || core.BlockedBy != "smoke" { + t.Fatalf("a line through the cloud should be blocked by smoke: %+v", core) + } + if core.Depth < 2*res.Threshold { + t.Fatalf("core depth %.2f is not comfortably over the threshold", core.Depth) + } + if over.Blocked || over.WorldBlocked { + t.Fatalf("a line over the top of the cloud is open: %+v", over) + } + if over.Depth <= 0 || over.Depth >= res.Threshold { + t.Fatalf("a line grazing the top should carry some smoke but not enough: %.2f", over.Depth) + } + if away.Blocked || away.Depth != 0 || away.WorldBlocked { + t.Fatalf("a line 500 units away should be untouched: %+v", away) + } +} + +func TestOnewayEndpointOnARealMap(t *testing.T) { + serveLocalMeshes(t) + at := mirageSpot + floor := floorUnder(t, at) + w := post(t, handleOneway, parser.OneWayRequest{ + Map: "de_mirage", + At: &at, + Pairs: []parser.SightlinePair{{ + From: parser.Point{X: at.X, Y: at.Y - 200, Z: floor}, + To: parser.Point{X: at.X, Y: at.Y + 200, Z: floor}, + }}, + }) + if w.Code != http.StatusOK { + t.Fatalf("status %d: %s", w.Code, w.Body.String()) + } + var res parser.OneWayResponse + if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil { + t.Fatalf("decode: %v", err) + } + if len(res.Caveats) == 0 { + t.Fatal("the caveats travel with every response") + } + got := res.Results[0] + if len(got.Stances) != 4 { + t.Fatalf("expected four stance pairings, got %d", len(got.Stances)) + } + // Two players on the same floor either side of a ground-level smoke: the + // cloud swallows both of them whichever way they stand. + if got.OneWay { + t.Fatalf("a symmetric pair should not be reported as a one-way: %+v", got) + } + for _, st := range got.Stances { + if st.AToB.Visible || st.BToA.Visible { + t.Fatalf("neither side sees through a full cloud: %+v", st) + } + } +} + +// This runs as a shared service behind a UI that will fire a preview per click, +// while the same process is parsing demos. Concurrent callers must get the same +// answer, and the mesh must be loaded once rather than raced into. +func TestSmokeVolumeEndpointIsSafeUnderConcurrency(t *testing.T) { + serveLocalMeshes(t) + body, err := json.Marshal(parser.SmokeVolumeRequest{ + Map: "de_mirage", X: mirageSpot.X, Y: mirageSpot.Y, Z: mirageSpot.Z, + }) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + const callers = 8 + var wg sync.WaitGroup + codes := make([]int, callers) + bodies := make([]string, callers) + for i := 0; i < callers; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + w := httptest.NewRecorder() + handleSmokeVolume(w, httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))) + codes[i], bodies[i] = w.Code, w.Body.String() + }(i) + } + wg.Wait() + + for i := range codes { + if codes[i] != http.StatusOK { + t.Fatalf("caller %d: status %d: %s", i, codes[i], bodies[i]) + } + if bodies[i] != bodies[0] { + t.Fatalf("caller %d got a different volume for the same point", i) + } + } +} diff --git a/internal/geometry/bvh.go b/internal/geometry/bvh.go index 297c387..d2ec162 100644 --- a/internal/geometry/bvh.go +++ b/internal/geometry/bvh.go @@ -219,9 +219,22 @@ func (m *Mesh) anyHit(orig, dir r3.Vector, tmin, tmax float64) bool { // nearestHit returns the closest triangle distance along a (unit) ray. func (m *Mesh) nearestHit(orig, dir r3.Vector) (float64, bool) { + t, _, ok := m.nearestHitTri(orig, dir) + return t, ok +} + +// nearestHitTri is nearestHit plus the triangle that produced the hit, for +// callers that need the surface (its normal) and not just the range to it. +// +// Ties are broken by traversal order rather than arbitrarily: `<` keeps the +// first triangle found at a given distance, and the traversal itself is a +// fixed stack walk over a deterministically built tree. Two processes loading +// the same .tri therefore pick the same triangle, which is what lets a +// simulation run against two meshes be compared at all. +func (m *Mesh) nearestHitTri(orig, dir r3.Vector) (float64, *triangle, bool) { inv := r3.Vector{X: safeInv(dir.X), Y: safeInv(dir.Y), Z: safeInv(dir.Z)} best := math.Inf(1) - found := false + var hit *triangle stack := make([]int, 0, 64) stack = append(stack, 0) for len(stack) > 0 { @@ -235,12 +248,12 @@ func (m *Mesh) nearestHit(orig, dir r3.Vector) (float64, bool) { for i := n.start; i < n.start+n.count; i++ { if t, ok := rayTriangle(orig, dir, &m.tris[i]); ok && t > 1e-4 && t < best { best = t - found = true + hit = &m.tris[i] } } continue } stack = append(stack, n.left, n.right) } - return best, found + return best, hit, hit != nil } diff --git a/internal/geometry/cache_test.go b/internal/geometry/cache_test.go index 70e088c..f3f6f9c 100644 --- a/internal/geometry/cache_test.go +++ b/internal/geometry/cache_test.go @@ -232,3 +232,88 @@ func TestCacheCanBeDisabled(t *testing.T) { t.Errorf("the entry map should be empty too when disabled, holds %d", got) } } + +func TestResolveMeshRevision(t *testing.T) { + t.Setenv("MAP_MESH_CDN", "https://example.test/pinned/") + for ref, want := range map[string]string{ + "": "https://example.test/pinned", + "https://mirror.test/meshes/": "https://mirror.test/meshes", + "http://127.0.0.1:8080": "http://127.0.0.1:8080", + "17595823-5": "https://cdn.jsdelivr.net/gh/5stackgg/replay-map-meshes@17595823-5", + "replay-map-meshes@17595823-5": "https://cdn.jsdelivr.net/gh/5stackgg/replay-map-meshes@17595823-5", + "5stackgg/replay-map-meshes@17595823-5": "https://cdn.jsdelivr.net/gh/5stackgg/replay-map-meshes@17595823-5", + "someone-else/other-meshes@v1.2.3": "https://cdn.jsdelivr.net/gh/someone-else/other-meshes@v1.2.3", + } { + got, err := ResolveMeshRevision(ref) + if err != nil { + t.Errorf("ResolveMeshRevision(%q): %v", ref, err) + continue + } + if got != want { + t.Errorf("ResolveMeshRevision(%q) = %q, want %q", ref, got, want) + } + } + // A revision comes off a request body, so anything that could walk out of + // the pinned path has to be refused rather than pasted into a URL. + for _, bad := range []string{ + "../../../etc", "tag/../..", "repo@tag/nested", "..", "a b", "tag?query=1", + "owner/repo@", "@tag", "owner/repo/extra@tag", + } { + if got, err := ResolveMeshRevision(bad); err == nil { + t.Errorf("ResolveMeshRevision(%q) should be refused, got %q", bad, got) + } + } +} + +// Drift detection stands or falls on this: the same map at two revisions must +// be two cache entries, or the second load would hand back the first's mesh and +// every lineup would look unchanged. +func TestRevisionsAreSeparateCacheEntries(t *testing.T) { + one := triBlob([3]r3.Vector{{X: 0, Y: -50, Z: -50}, {X: 0, Y: 50, Z: -50}, {X: 0, Y: 50, Z: 50}}) + two := append(append([]byte(nil), one...), triBlob( + [3]r3.Vector{{X: 10, Y: -50, Z: -50}, {X: 10, Y: 50, Z: -50}, {X: 10, Y: 50, Z: 50}}, + [3]r3.Vector{{X: 20, Y: -50, Z: -50}, {X: 20, Y: 50, Z: -50}, {X: 20, Y: 50, Z: 50}}, + )...) + + serve := func(blob []byte) string { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(blob) + })) + t.Cleanup(srv.Close) + return srv.URL + } + oldRev, newRev := serve(one), serve(two) + resetCache() + t.Cleanup(resetCache) + t.Setenv("MAP_MESH_CACHE", "4") + + before, err := LoadRevision("de_x", oldRev) + if err != nil || before == nil { + t.Fatalf("LoadRevision(old) = %v, %v", before, err) + } + after, err := LoadRevision("de_x", newRev) + if err != nil || after == nil { + t.Fatalf("LoadRevision(new) = %v, %v", after, err) + } + if before == after { + t.Fatal("two revisions of one map came back as the same mesh") + } + if before.Triangles() != 1 || after.Triangles() != 3 { + t.Fatalf("wrong geometry per revision: old %d triangles, new %d", + before.Triangles(), after.Triangles()) + } + if got := cachedMeshCount(); got != 2 { + t.Fatalf("both revisions should be resident, cache holds %d", got) + } + // And each is still memoized on its own key. + if again, _ := LoadRevision("de_x", oldRev); again != before { + t.Fatal("the old revision should have come back out of the cache") + } +} + +func TestMaxCachedMeshesReportsTheBound(t *testing.T) { + t.Setenv("MAP_MESH_CACHE", "7") + if got := MaxCachedMeshes(); got != 7 { + t.Fatalf("MaxCachedMeshes() = %d, want 7", got) + } +} diff --git a/internal/geometry/geometry_test.go b/internal/geometry/geometry_test.go index ccf5854..6d488a2 100644 --- a/internal/geometry/geometry_test.go +++ b/internal/geometry/geometry_test.go @@ -343,3 +343,62 @@ func TestNormalizeMapName(t *testing.T) { } } } + +// A .tri is a soup of unwound triangles: the same wall can be stored facing +// either way, so the normal has to be taken relative to the ray that found it. +// A bounce computed off a flipped normal drives the grenade into the wall. +func TestRayHitSurfaceNormalAlwaysFacesTheRay(t *testing.T) { + m := wallMesh() + for _, tc := range []struct { + name string + origin r3.Vector + dir r3.Vector + wantX float64 + }{ + {"approaching from -X", r3.Vector{X: -30}, r3.Vector{X: 1}, -1}, + {"approaching from +X", r3.Vector{X: 30}, r3.Vector{X: -1}, 1}, + } { + hit, ok := m.RayHitSurface(tc.origin, tc.dir) + if !ok { + t.Fatalf("%s: expected a hit", tc.name) + } + if math.Abs(hit.Distance-30) > 1e-6 { + t.Errorf("%s: distance %v, want 30", tc.name, hit.Distance) + } + if math.Abs(hit.Normal.X-tc.wantX) > 1e-9 || hit.Normal.Y != 0 || hit.Normal.Z != 0 { + t.Errorf("%s: normal %v, want X=%v", tc.name, hit.Normal, tc.wantX) + } + if d := hit.Normal.Dot(tc.dir); d >= 0 { + t.Errorf("%s: normal points along the ray (dot %v)", tc.name, d) + } + } +} + +func TestRayHitSurfaceMisses(t *testing.T) { + m := wallMesh() + if _, ok := m.RayHitSurface(r3.Vector{X: -30}, r3.Vector{X: -1}); ok { + t.Error("a ray pointing away from the wall should miss") + } + if _, ok := m.RayHitSurface(r3.Vector{X: -30}, r3.Vector{}); ok { + t.Error("a zero-length direction should miss rather than divide by zero") + } + var nilMesh *Mesh + if _, ok := nilMesh.RayHitSurface(r3.Vector{}, r3.Vector{X: 1}); ok { + t.Error("a nil mesh has no surfaces") + } +} + +func TestBoundsCoverTheGeometry(t *testing.T) { + m := wallMesh() + lo, hi, ok := m.Bounds() + if !ok { + t.Fatal("a built mesh should report bounds") + } + if lo.Y > -50 || lo.Z > -50 || hi.Y < 50 || hi.Z < 50 { + t.Fatalf("bounds %v..%v do not cover the wall", lo, hi) + } + var nilMesh *Mesh + if _, _, ok := nilMesh.Bounds(); ok { + t.Error("a nil mesh has no bounds") + } +} diff --git a/internal/geometry/load.go b/internal/geometry/load.go index 11a70fe..9dc54a9 100644 --- a/internal/geometry/load.go +++ b/internal/geometry/load.go @@ -20,6 +20,16 @@ import ( // empty to disable geometry entirely (offline / tests). const defaultMeshCDN = "https://cdn.jsdelivr.net/gh/5stackgg/replay-map-meshes@17595823-4" +// The mesh sets are published as tagged snapshots of one GitHub repo and +// served through jsDelivr, so a revision is fully identified by its tag. These +// let a caller name a revision other than the process default — which is what +// drift detection needs, since it has to hold the mesh from before a map patch +// and the one from after it at the same time. +const ( + defaultMeshOwner = "5stackgg" + defaultMeshRepo = "5stackgg/replay-map-meshes" +) + // maxMeshBytes caps a downloaded .tri, matching the web's MAX_MESH_BYTES. const maxMeshBytes = 96 << 20 @@ -89,7 +99,9 @@ func touch(key string) { evict := lru[len(lru)-1] lru = lru[:len(lru)-1] delete(cache, evict) - fmt.Fprintf(os.Stderr, "[geometry] evicted mesh for %s (%d cached)\n", evict, len(lru)) + // The key is base + "\n" + map; only the map half is worth logging. + _, name, _ := strings.Cut(evict, "\n") + fmt.Fprintf(os.Stderr, "[geometry] evicted mesh for %s (%d cached)\n", name, len(lru)) } } @@ -111,15 +123,93 @@ func cdnBase() (string, bool) { return defaultMeshCDN, true } +// MaxCachedMeshes reports the bound the mesh cache is running under, so a +// caller that needs more than one mesh resident at a time (drift detection +// holds two) can warn when the deployment is configured for fewer. +func MaxCachedMeshes() int { + return maxCachedMeshes() +} + +// ResolveMeshRevision turns a mesh reference into the base URL its .tri files +// live under. Accepted forms, most to least specific: +// +// "https://host/path" used verbatim (mirrors, tests) +// "5stackgg/replay-map-meshes@17595823-5" owner, repo and tag +// "replay-map-meshes@17595823-5" default owner +// "17595823-5" default owner and repo +// "" the process default (MAP_MESH_CDN) +// +// A reference is part of a request body, so the repo and tag are charset- +// checked rather than pasted into a URL: a "tag" containing a slash or a dot +// segment would otherwise walk out of the pinned path and fetch something else +// entirely. +func ResolveMeshRevision(ref string) (string, error) { + ref = strings.TrimSpace(ref) + if ref == "" { + base, _ := cdnBase() + return strings.TrimRight(base, "/"), nil + } + if strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://") { + return strings.TrimRight(ref, "/"), nil + } + repo, tag := defaultMeshRepo, ref + if i := strings.LastIndex(ref, "@"); i >= 0 { + repo, tag = ref[:i], ref[i+1:] + if !strings.Contains(repo, "/") { + repo = defaultMeshOwner + "/" + repo + } + } + owner, name, ok := strings.Cut(repo, "/") + if !ok || !safeRefPart(owner) || !safeRefPart(name) || !safeRefPart(tag) { + return "", fmt.Errorf("mesh revision %q is not a tag, owner/repo@tag, or an http(s) base", ref) + } + return "https://cdn.jsdelivr.net/gh/" + owner + "/" + name + "@" + tag, nil +} + +func safeRefPart(s string) bool { + if s == "" || s == "." || s == ".." { + return false + } + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': + case r == '.' || r == '_' || r == '-': + default: + return false + } + } + return true +} + +// LoadRevision is Load against a named mesh revision rather than the process +// default. Two revisions of the same map are separate cache entries, so both +// can be resident at once — mind MaxCachedMeshes when they are. +func LoadRevision(mapName, revision string) (*Mesh, error) { + base, err := ResolveMeshRevision(revision) + if err != nil { + return nil, err + } + return loadFrom(base, mapName) +} + // Load returns the collision mesh for a map, or (nil, nil) when geometry is // unavailable (disabled, unknown map, or no .tri published) — callers treat a // nil mesh as "always visible". Results are cached process-wide, bounded to // maxCachedMeshes built meshes. func Load(mapName string) (*Mesh, error) { - key := normalizeMapName(mapName) - if key == "" { + base, _ := cdnBase() + return loadFrom(strings.TrimRight(base, "/"), mapName) +} + +// loadFrom is Load against an already-resolved base. The cache key carries the +// base as well as the map, so the same map at two revisions never collides — +// the whole point of drift detection is that those two meshes differ. +func loadFrom(base, mapName string) (*Mesh, error) { + name := normalizeMapName(mapName) + if name == "" { return nil, nil } + key := base + "\n" + name cacheMu.Lock() c := cache[key] if c == nil { @@ -127,7 +217,7 @@ func Load(mapName string) (*Mesh, error) { cache[key] = c } cacheMu.Unlock() - c.once.Do(func() { c.mesh, c.err = fetchAndBuild(key) }) + c.once.Do(func() { c.mesh, c.err = fetchAndBuild(base, name) }) cacheMu.Lock() switch { @@ -152,8 +242,7 @@ func Load(mapName string) (*Mesh, error) { return c.mesh, c.err } -func fetchAndBuild(key string) (*Mesh, error) { - base, _ := cdnBase() +func fetchAndBuild(base, key string) (*Mesh, error) { if base == "" { return nil, nil // geometry disabled } diff --git a/internal/geometry/mesh.go b/internal/geometry/mesh.go index d8b334b..8b9886c 100644 --- a/internal/geometry/mesh.go +++ b/internal/geometry/mesh.go @@ -158,3 +158,56 @@ func safeInv(x float64) float64 { } return 1.0 / x } + +// SurfaceHit is the nearest world surface along a ray. +type SurfaceHit struct { + // Distance is the range from the ray origin, in source units. + Distance float64 + // Normal is the unit surface normal, always oriented back towards the ray + // (Normal·dir < 0). The .tri meshes are unwound — a wall's triangles can + // face either way — so a normal taken straight from the winding is only + // right half the time, and a bounce computed off a flipped one drives the + // grenade through the wall instead of off it. + Normal r3.Vector +} + +// RayHitSurface returns the nearest surface along a ray (dir need not be +// normalized), with the normal to bounce off it. ok is false when nothing is +// hit, or when the mesh is empty. +func (m *Mesh) RayHitSurface(origin, dir r3.Vector) (SurfaceHit, bool) { + if m == nil || len(m.tris) == 0 { + return SurfaceHit{}, false + } + l := math.Sqrt(dir.X*dir.X + dir.Y*dir.Y + dir.Z*dir.Z) + if l < 1e-9 { + return SurfaceHit{}, false + } + d := r3.Vector{X: dir.X / l, Y: dir.Y / l, Z: dir.Z / l} + t, tri, ok := m.nearestHitTri(origin, d) + if !ok { + return SurfaceHit{}, false + } + x0, y0, z0, x1, y1, z1, x2, y2, z2 := tri.corners() + e1 := r3.Vector{X: x1 - x0, Y: y1 - y0, Z: z1 - z0} + e2 := r3.Vector{X: x2 - x0, Y: y2 - y0, Z: z2 - z0} + n := e1.Cross(e2) + nl := n.Norm() + if nl < 1e-12 { + return SurfaceHit{}, false // degenerate triangle: no surface to bounce off + } + n = n.Mul(1 / nl) + if n.Dot(d) > 0 { + n = n.Mul(-1) + } + return SurfaceHit{Distance: t, Normal: n}, true +} + +// Bounds is the mesh's world AABB, straight off the BVH root. ok is false for +// an empty mesh. Callers use it to notice a simulation that has left the map +// rather than integrating it forever. +func (m *Mesh) Bounds() (min, max r3.Vector, ok bool) { + if m == nil || len(m.nodes) == 0 { + return r3.Vector{}, r3.Vector{}, false + } + return m.nodes[0].min, m.nodes[0].max, true +} diff --git a/internal/parser/grenades.go b/internal/parser/grenades.go index ce65b9c..fef597d 100644 --- a/internal/parser/grenades.go +++ b/internal/parser/grenades.go @@ -48,6 +48,9 @@ func (s *state) onGrenadeProjectileThrow(e events.GrenadeProjectileThrow) { ev.ThrowerTeam = teamCode(thrower.Team) } s.res.GrenadeThrows = append(s.res.GrenadeThrows, ev) + // A lineup mined from this throw is only reproducible if the thrower's + // stance, spot and view angles at the release are known to the tick. + s.burstPositions(ev.Tick) if e.Projectile.Entity != nil { entID := e.Projectile.Entity.ID() diff --git a/internal/parser/nades.go b/internal/parser/nades.go new file mode 100644 index 0000000..e96e482 --- /dev/null +++ b/internal/parser/nades.go @@ -0,0 +1,432 @@ +package parser + +import ( + "encoding/base64" + "errors" + "fmt" + "math" + + "github.com/5stackgg/demo-parser/internal/geometry" + "github.com/golang/geo/r3" +) + +// The lineup side of the smoke model. +// +// buildSmokeVolume is a pure function of (mesh, point): it never looks at the +// demo, only at the map. So the exact cloud a demo-mined smoke produced can +// also be produced for a point nobody has ever thrown at — which is what a +// lineup library needs, since a lineup is a throw description, not a replay. +// +// Everything here reuses that one function rather than approximating it, so a +// bloom previewed in the browser, a bloom drawn on the 2D radar, and the volume +// the parser's own sightline stats were computed against are the same grid. + +// Point is a world position in raw CS2 source units (Z up) — the same space as +// PositionEyes(), the .tri meshes, and the smoke volumes. +type Point struct { + X float64 `json:"x"` + Y float64 `json:"y"` + Z float64 `json:"z"` +} + +func (p Point) vec() r3.Vector { + return r3.Vector{X: p.X, Y: p.Y, Z: p.Z} +} + +func (p Point) valid() bool { + for _, c := range [3]float64{p.X, p.Y, p.Z} { + if math.IsNaN(c) || math.IsInf(c, 0) { + return false + } + } + return true +} + +// DefaultBlockThreshold is the optical depth at which a sightline counts as +// blocked, in cell widths of fully dense smoke. +// +// It is the same constant the parser's own stats use (blockingDepth), so the +// answer this service gives and the answer baked into a parsed match agree. +// Read as Beer-Lambert: e^-3 leaves about 5% of the target's contrast, which is +// the point a silhouette stops being usable. It is a request field because it +// is a judgement, not a measurement — a caller who wants "could a good player +// have picked them out" should raise it, and one asking "was the model clean" +// should lower it. +const DefaultBlockThreshold = blockingDepth + +// Bounds on what one request may ask for. This runs as a shared service, and a +// volume build is thousands of raycasts against a mesh with a hundred thousand +// triangles. +const ( + maxSightlinePairs = 512 + maxRequestClouds = 16 + // A supplied grid is decoded into memory, so its cell count is capped. The + // parser's own grids are 19³; this leaves room for a coarser voxel size + // over a much larger cloud without letting a request allocate freely. + maxSuppliedCells = 1 << 20 +) + +var ( + // ErrNoMesh means the map has no published collision mesh, so nothing here + // can be answered — a smoke's shape is a property of the map. + ErrNoMesh = errors.New("no collision mesh for map") + // ErrSmokeSealed means the flood found almost no free space around the + // point: it resolved inside geometry, or on a surface the mesh is missing. + // The caller gave a point a grenade could not come to rest at. + ErrSmokeSealed = errors.New("smoke point is sealed inside geometry") +) + +// SmokeVolumeRequest asks for the bloom at one point on one map. +type SmokeVolumeRequest struct { + Map string `json:"map"` + X float64 `json:"x"` + Y float64 `json:"y"` + Z float64 `json:"z"` +} + +func (r SmokeVolumeRequest) point() Point { + return Point{X: r.X, Y: r.Y, Z: r.Z} +} + +// SmokeVolumeResponse carries the EventSmokeVolume fields inline, so the blob +// is byte-for-byte the shape the playback pipeline already renders (ox/oy/oz, +// vs, dx/dy/dz, den) and a consumer can share one decoder for both. +type SmokeVolumeResponse struct { + Map string `json:"map"` + EventSmokeVolume + // Cells is how many voxels hold any smoke at all, before the nibble + // packing. A rough measure of how much space the cloud found. + Cells int `json:"cells"` + // Radius is the reach the cloud was flooded to, so a caller can size a + // preview without hardcoding the constant. + Radius float64 `json:"radius"` +} + +// SmokeVolume computes the bloom for a point. mesh must be the map's collision +// mesh; a nil mesh is ErrNoMesh, since without geometry there is no shape. +func SmokeVolume(mesh *geometry.Mesh, req SmokeVolumeRequest) (SmokeVolumeResponse, error) { + if mesh == nil { + return SmokeVolumeResponse{}, ErrNoMesh + } + at := req.point() + if !at.valid() { + return SmokeVolumeResponse{}, errors.New("x, y and z must be finite") + } + vol, sealed := buildSmokeVolume(mesh, at.vec()) + if sealed || vol == nil { + return SmokeVolumeResponse{}, ErrSmokeSealed + } + return SmokeVolumeResponse{ + Map: req.Map, + EventSmokeVolume: vol.export(0, 0, 0), + Cells: vol.count(), + Radius: smokeRadius, + }, nil +} + +// SightlinePair is one eye position to another. Both ends are eye positions, +// not feet: this endpoint answers "is this line blocked", and knows nothing +// about who is standing where. Stance belongs to OneWay. +type SightlinePair struct { + From Point `json:"from"` + To Point `json:"to"` +} + +// CloudSpec is the smoke a request is asking about — either a point to bloom +// from, or a grid already computed (typically one /smoke-volume handed back). +// Supplying the grid skips the flood, which is the expensive half. +type CloudSpec struct { + At *Point `json:"at,omitempty"` + // Volume is an EventSmokeVolume as exported by /smoke-volume or carried in + // a playback blob. Its density is quantised to 16 levels, so depths through + // a round-tripped grid differ from the source by up to one level per cell. + Volume *EventSmokeVolume `json:"volume,omitempty"` +} + +// SightlineRequest asks, for each pair, whether smoke and geometry block it. +type SightlineRequest struct { + Map string `json:"map"` + // Smokes is the full form. Smoke is the one-cloud shorthand, and At is the + // shorthand for that shorthand, since most callers have a detonation point + // and nothing else. + Smokes []CloudSpec `json:"smokes,omitempty"` + Smoke *CloudSpec `json:"smoke,omitempty"` + At *Point `json:"at,omitempty"` + Pairs []SightlinePair `json:"pairs"` + // Threshold is the optical depth at which a line counts as blocked; + // DefaultBlockThreshold when nil or non-positive. + Threshold *float64 `json:"threshold,omitempty"` +} + +func (r SightlineRequest) clouds() []CloudSpec { + specs := append([]CloudSpec(nil), r.Smokes...) + if r.Smoke != nil { + specs = append(specs, *r.Smoke) + } + if r.At != nil { + specs = append(specs, CloudSpec{At: r.At}) + } + return specs +} + +func (r SightlineRequest) threshold() float64 { + if r.Threshold == nil || *r.Threshold <= 0 { + return DefaultBlockThreshold + } + return *r.Threshold +} + +// SightlineResult is one pair's answer. +type SightlineResult struct { + // Blocked is the headline: the far end could not be made out from the near + // end, whether the map or the smoke did it. + Blocked bool `json:"blocked"` + // BlockedBy is "world" when the map alone blocks the line (the smoke is + // irrelevant to it), "smoke" when the clouds do, and empty when it is open. + // A line the map already blocks is never attributed to smoke, so a lineup + // cannot take credit for a wall. + BlockedBy string `json:"blocked_by,omitempty"` + WorldBlocked bool `json:"world_blocked"` + // Depth is the smoke on the line, in cell widths of fully dense smoke, and + // Transmittance is e^-Depth: the fraction of the target's contrast that + // survives. Reported whatever the threshold, so a caller can re-cut the + // answer without another request. + Depth float64 `json:"depth"` + Transmittance float64 `json:"transmittance"` + // PerSmoke splits Depth across the request's clouds, in the order they were + // resolved. Lets a caller see which smoke of a set is doing the work. + PerSmoke []float64 `json:"per_smoke,omitempty"` + Distance float64 `json:"distance"` +} + +// CloudInfo describes a cloud as the service resolved it, so a caller can tell +// what was actually measured — particularly when a point failed to bloom. +type CloudInfo struct { + Center Point `json:"center"` + // Model is "voxel" for a flooded or supplied grid and "sphere" for the + // fallback used when a point seals. A sphere ignores the map, so an answer + // resting on one is a guess; it is named rather than hidden. + Model string `json:"model"` + Cells int `json:"cells,omitempty"` + Radius float64 `json:"radius"` + Sealed bool `json:"sealed,omitempty"` +} + +type SightlineResponse struct { + Map string `json:"map"` + Threshold float64 `json:"threshold"` + Smokes []CloudInfo `json:"smokes"` + Results []SightlineResult `json:"results"` +} + +// Sightlines answers a batch of point-to-point visibility questions against a +// map and a set of clouds. +func Sightlines(mesh *geometry.Mesh, req SightlineRequest) (SightlineResponse, error) { + if mesh == nil { + return SightlineResponse{}, ErrNoMesh + } + if len(req.Pairs) == 0 { + return SightlineResponse{}, errors.New("pairs must not be empty") + } + if len(req.Pairs) > maxSightlinePairs { + return SightlineResponse{}, fmt.Errorf("too many pairs: %d (max %d)", len(req.Pairs), maxSightlinePairs) + } + clouds, infos, err := resolveClouds(mesh, req.clouds()) + if err != nil { + return SightlineResponse{}, err + } + + threshold := req.threshold() + out := SightlineResponse{Map: req.Map, Threshold: threshold, Smokes: infos} + out.Results = make([]SightlineResult, 0, len(req.Pairs)) + for i, pair := range req.Pairs { + if !pair.From.valid() || !pair.To.valid() { + return SightlineResponse{}, fmt.Errorf("pair %d: coordinates must be finite", i) + } + out.Results = append(out.Results, sightline(mesh, clouds, pair.From.vec(), pair.To.vec(), threshold)) + } + return out, nil +} + +func sightline(mesh *geometry.Mesh, clouds []resolvedCloud, from, to r3.Vector, threshold float64) SightlineResult { + res := SightlineResult{ + WorldBlocked: mesh.Occluded(from, to), + Distance: from.Sub(to).Norm(), + } + if len(clouds) > 0 { + res.PerSmoke = make([]float64, len(clouds)) + for i, c := range clouds { + d := c.depth(from, to) + res.PerSmoke[i] = d + res.Depth += d + } + } + res.Transmittance = math.Exp(-res.Depth) + switch { + case res.WorldBlocked: + res.Blocked, res.BlockedBy = true, "world" + case res.Depth >= threshold: + res.Blocked, res.BlockedBy = true, "smoke" + } + return res +} + +// resolvedCloud is one cloud ready to be integrated along a segment. +type resolvedCloud struct { + // vol is nil for the sphere fallback. + vol *smokeVolume + center r3.Vector + // radius gates the voxel walk (cells further than this from center are not + // counted, which is how the parser models a cloud still billowing out) and + // is the sphere's radius in the fallback. For a settled cloud it is set + // wide enough to include every cell. + radius float64 +} + +// depth is how much smoke this cloud puts on a segment, in cell widths of full +// density. +func (c resolvedCloud) depth(from, to r3.Vector) float64 { + if c.vol != nil { + return c.vol.opticalDepth(from, to, c.center, c.radius, nil) + } + // Sphere fallback: a chord through a uniformly dense ball. Expressed in the + // same cell-width units so a threshold means the same thing either way. + return sphereChord(from, to, c.center, c.radius) / smokeVoxelSize +} + +func resolveClouds(mesh *geometry.Mesh, specs []CloudSpec) ([]resolvedCloud, []CloudInfo, error) { + if len(specs) > maxRequestClouds { + return nil, nil, fmt.Errorf("too many smokes: %d (max %d)", len(specs), maxRequestClouds) + } + clouds := make([]resolvedCloud, 0, len(specs)) + infos := make([]CloudInfo, 0, len(specs)) + for i, spec := range specs { + switch { + case spec.Volume != nil: + vol, err := volumeFromExport(*spec.Volume) + if err != nil { + return nil, nil, fmt.Errorf("smoke %d: %w", i, err) + } + center, radius := vol.boundingSphere() + clouds = append(clouds, resolvedCloud{vol: vol, center: center, radius: radius}) + infos = append(infos, CloudInfo{ + Center: Point{X: center.X, Y: center.Y, Z: center.Z}, + Model: "voxel", + Cells: vol.count(), + Radius: radius, + }) + case spec.At != nil: + at := *spec.At + if !at.valid() { + return nil, nil, fmt.Errorf("smoke %d: coordinates must be finite", i) + } + center := at.vec() + info := CloudInfo{Center: at, Model: "voxel", Radius: smokeRadius} + vol, sealed := buildSmokeVolume(mesh, center) + if sealed || vol == nil { + // Answering with nothing would report every sightline through + // the cloud as open, which is a worse lie than a sphere. + info.Model, info.Sealed = "sphere", true + clouds = append(clouds, resolvedCloud{center: center, radius: smokeRadius}) + infos = append(infos, info) + continue + } + // The cloud is settled, so nothing should be trimmed by the bloom + // gate: reach past the far corner of the grid. + _, radius := vol.boundingSphere() + clouds = append(clouds, resolvedCloud{vol: vol, center: center, radius: math.Max(radius, smokeRadius)}) + info.Cells = vol.count() + infos = append(infos, info) + default: + return nil, nil, fmt.Errorf("smoke %d: needs either at or volume", i) + } + } + return clouds, infos, nil +} + +// boundingSphere returns the centre of the grid and a radius that reaches every +// cell in it, so a settled cloud is never trimmed by the bloom gate. +func (v *smokeVolume) boundingSphere() (r3.Vector, float64) { + half := r3.Vector{ + X: float64(v.dim[0]) * v.size / 2, + Y: float64(v.dim[1]) * v.size / 2, + Z: float64(v.dim[2]) * v.size / 2, + } + center := r3.Vector{ + X: v.origin.X + half.X, + Y: v.origin.Y + half.Y, + Z: v.origin.Z + half.Z, + } + return center, half.Norm() +} + +// volumeFromExport rebuilds a density field from the wire form export produces. +// Quantisation is not undone — a cell that left as one of 16 levels comes back +// as the midpoint of that level — so a round-tripped grid gives depths within +// about one level per cell of the original. +func volumeFromExport(ex EventSmokeVolume) (*smokeVolume, error) { + if ex.DimX <= 0 || ex.DimY <= 0 || ex.DimZ <= 0 { + return nil, errors.New("volume dims must be positive") + } + if ex.VoxelSize <= 0 { + return nil, errors.New("volume voxel size must be positive") + } + total := ex.DimX * ex.DimY * ex.DimZ + if total > maxSuppliedCells { + return nil, fmt.Errorf("volume is %d cells (max %d)", total, maxSuppliedCells) + } + packed, err := base64.StdEncoding.DecodeString(ex.Density) + if err != nil { + return nil, fmt.Errorf("volume density is not valid base64: %w", err) + } + if len(packed) != (total+1)/2 { + return nil, fmt.Errorf("volume density is %d bytes, want %d for %d cells", + len(packed), (total+1)/2, total) + } + v := &smokeVolume{ + origin: r3.Vector{X: float64(ex.OriginX), Y: float64(ex.OriginY), Z: float64(ex.OriginZ)}, + size: float64(ex.VoxelSize), + dim: [3]int{ex.DimX, ex.DimY, ex.DimZ}, + density: make([]uint8, total), + } + for n := 0; n < total; n++ { + q := packed[n>>1] & 0x0f + if n&1 == 1 { + q = packed[n>>1] >> 4 + } + if q == 0 { + continue + } + // export rounds a cell up into its level (level = d*15/max + 1), so the + // densities that produced a given level span [(q-1), q) levels. Decode + // to the middle of that span rather than its top, or every cell comes + // back heavier than it went in and long chords drift measurably. + v.density[n] = uint8(int(q)*densityMax/15 - densityMax/30) + } + return v, nil +} + +// sphereChord is the length of the part of a segment that lies inside a sphere. +func sphereChord(from, to, center r3.Vector, radius float64) float64 { + d := to.Sub(from) + segLen := d.Norm() + if segLen < 1e-9 || radius <= 0 { + return 0 + } + m := from.Sub(center) + // |m + t*d|² = r², solved for the parametric range inside the sphere. + a := d.Dot(d) + b := 2 * m.Dot(d) + c := m.Dot(m) - radius*radius + disc := b*b - 4*a*c + if disc <= 0 { + return 0 + } + sq := math.Sqrt(disc) + t0 := math.Max((-b-sq)/(2*a), 0) + t1 := math.Min((-b+sq)/(2*a), 1) + if t1 <= t0 { + return 0 + } + return (t1 - t0) * segLen +} diff --git a/internal/parser/nades_test.go b/internal/parser/nades_test.go new file mode 100644 index 0000000..3858349 --- /dev/null +++ b/internal/parser/nades_test.go @@ -0,0 +1,348 @@ +package parser + +import ( + "math" + "testing" + + "github.com/5stackgg/demo-parser/internal/geometry" + "github.com/golang/geo/r3" +) + +// openSpaceMesh is a mesh whose only triangle is nowhere near the origin, so +// geometry is available (nothing falls back) but nothing obstructs. +func openSpaceMesh(t *testing.T) *geometry.Mesh { + t.Helper() + return meshFromBlob(t, triBlob([3]r3.Vector{ + {X: 9000, Y: 9000, Z: 9000}, + {X: 9100, Y: 9000, Z: 9000}, + {X: 9100, Y: 9100, Z: 9000}, + })) +} + +func TestSmokeVolumeEndpointNeedsAMesh(t *testing.T) { + if _, err := SmokeVolume(nil, SmokeVolumeRequest{Map: "de_nowhere"}); err != ErrNoMesh { + t.Fatalf("no mesh should be ErrNoMesh, got %v", err) + } +} + +// A point inside geometry cannot bloom. Reporting an empty grid would tell the +// UI the smoke is fine and block nothing, so it is an error instead. +func TestSmokeVolumeRejectsASealedPoint(t *testing.T) { + // A closed box of six quads, with the query point outside it but buried + // deep in the solid half-space behind a wall the flood cannot escape. + mesh := meshFromBlob(t, sealedBoxTriBlob(24)) + _, err := SmokeVolume(mesh, SmokeVolumeRequest{Map: "de_test"}) + if err != ErrSmokeSealed { + t.Fatalf("a sealed point should be ErrSmokeSealed, got %v", err) + } +} + +func TestSmokeVolumeRejectsNonFiniteCoordinates(t *testing.T) { + mesh := openSpaceMesh(t) + req := SmokeVolumeRequest{Map: "de_test", X: math.NaN()} + if _, err := SmokeVolume(mesh, req); err == nil { + t.Fatal("NaN coordinates should be rejected") + } +} + +// The endpoint's job is to hand back exactly what the playback blob carries, so +// the browser can share one decoder between a mined lineup and a replay. +func TestSmokeVolumeMatchesTheParserVolume(t *testing.T) { + mesh := meshFromBlob(t, bigWallTriBlob()) + at := r3.Vector{X: -40} + + res, err := SmokeVolume(mesh, SmokeVolumeRequest{Map: "de_test", X: at.X, Y: at.Y, Z: at.Z}) + if err != nil { + t.Fatalf("smoke volume: %v", err) + } + want := mustVolume(mesh, at).export(0, 0, 0) + if res.EventSmokeVolume != want { + t.Fatalf("endpoint volume %+v differs from the parser's %+v", res.EventSmokeVolume, want) + } + if res.Cells < minCloudCells { + t.Fatalf("cells = %d, expected a real cloud", res.Cells) + } + if res.Radius != smokeRadius { + t.Fatalf("radius = %v, want %v", res.Radius, smokeRadius) + } +} + +func TestSightlinesBlockedClearAndGrazing(t *testing.T) { + mesh := openSpaceMesh(t) + at := Point{} + req := SightlineRequest{ + Map: "de_test", + At: &at, + Pairs: []SightlinePair{ + // Straight through the core. + {From: Point{X: -500}, To: Point{X: 500}}, + // Clipping the rim, where the cloud is thin enough to see through. + {From: Point{X: -500, Y: smokeRadius * 0.9}, To: Point{X: 500, Y: smokeRadius * 0.9}}, + // Nowhere near it. + {From: Point{X: -500, Y: 600}, To: Point{X: 500, Y: 600}}, + }, + } + res, err := Sightlines(mesh, req) + if err != nil { + t.Fatalf("sightlines: %v", err) + } + if res.Threshold != DefaultBlockThreshold { + t.Fatalf("threshold = %v, want the default %v", res.Threshold, DefaultBlockThreshold) + } + if len(res.Smokes) != 1 || res.Smokes[0].Model != "voxel" || res.Smokes[0].Sealed { + t.Fatalf("expected one flooded cloud, got %+v", res.Smokes) + } + + core, grazing, clear := res.Results[0], res.Results[1], res.Results[2] + if !core.Blocked || core.BlockedBy != "smoke" { + t.Fatalf("a line through the core should be blocked by smoke: %+v", core) + } + if core.Depth < DefaultBlockThreshold { + t.Fatalf("core depth %.2f is under the threshold", core.Depth) + } + if grazing.Blocked { + t.Fatalf("a line clipping the rim should not be blocked: %+v", grazing) + } + if grazing.Depth <= 0 || grazing.Depth >= DefaultBlockThreshold { + t.Fatalf("a grazing line should carry some smoke but not enough: depth %.2f", grazing.Depth) + } + if clear.Blocked || clear.Depth != 0 || clear.Transmittance != 1 { + t.Fatalf("a line away from the cloud should be untouched: %+v", clear) + } + // Transmittance is the reading the threshold is a judgement about, so it + // has to agree with the depth it came from. + if math.Abs(grazing.Transmittance-math.Exp(-grazing.Depth)) > 1e-9 { + t.Fatalf("transmittance %v does not match depth %v", grazing.Transmittance, grazing.Depth) + } +} + +// The threshold is a judgement, so a caller can move it — and moving it has to +// actually change the verdict rather than just the reported number. +func TestSightlineThresholdIsTunable(t *testing.T) { + mesh := openSpaceMesh(t) + at := Point{} + pair := SightlinePair{ + From: Point{X: -500, Y: smokeRadius * 0.9}, + To: Point{X: 500, Y: smokeRadius * 0.9}, + } + loose := 0.1 + res, err := Sightlines(mesh, SightlineRequest{ + Map: "de_test", At: &at, Pairs: []SightlinePair{pair}, Threshold: &loose, + }) + if err != nil { + t.Fatalf("sightlines: %v", err) + } + if !res.Results[0].Blocked { + t.Fatalf("the grazing line should block once the threshold drops to %v: %+v", loose, res.Results[0]) + } + if res.Threshold != loose { + t.Fatalf("threshold = %v, want %v", res.Threshold, loose) + } +} + +// A wall in the way is the map's doing, and a lineup does not get to claim it. +func TestSightlineAttributesAWallToTheWorld(t *testing.T) { + mesh := meshFromBlob(t, bigWallTriBlob()) + at := Point{X: -40} + res, err := Sightlines(mesh, SightlineRequest{ + Map: "de_test", At: &at, + Pairs: []SightlinePair{{From: Point{X: -300}, To: Point{X: 300}}}, + }) + if err != nil { + t.Fatalf("sightlines: %v", err) + } + got := res.Results[0] + if !got.Blocked || got.BlockedBy != "world" || !got.WorldBlocked { + t.Fatalf("the wall should own this one: %+v", got) + } +} + +// Handing back a volume and asking about it again is the round trip the web UI +// makes: preview once, then ask what it blocks without paying for the flood +// twice. The two answers have to agree. +func TestSightlineAcceptsASuppliedVolume(t *testing.T) { + mesh := openSpaceMesh(t) + at := Point{} + vol, err := SmokeVolume(mesh, SmokeVolumeRequest{Map: "de_test"}) + if err != nil { + t.Fatalf("smoke volume: %v", err) + } + + pairs := []SightlinePair{ + {From: Point{X: -500}, To: Point{X: 500}}, + {From: Point{X: -500, Y: smokeRadius * 0.9}, To: Point{X: 500, Y: smokeRadius * 0.9}}, + {From: Point{X: -500, Y: 600}, To: Point{X: 500, Y: 600}}, + } + fromPoint, err := Sightlines(mesh, SightlineRequest{Map: "de_test", At: &at, Pairs: pairs}) + if err != nil { + t.Fatalf("sightlines from point: %v", err) + } + supplied := vol.EventSmokeVolume + fromVolume, err := Sightlines(mesh, SightlineRequest{ + Map: "de_test", Smoke: &CloudSpec{Volume: &supplied}, Pairs: pairs, + }) + if err != nil { + t.Fatalf("sightlines from volume: %v", err) + } + for i := range pairs { + a, b := fromPoint.Results[i], fromVolume.Results[i] + if a.Blocked != b.Blocked { + t.Fatalf("pair %d: flooded says blocked=%v, round-tripped says %v", i, a.Blocked, b.Blocked) + } + // The wire form quantises density to 16 levels, so the depths differ + // slightly. One level over a long chord is a few tenths. + if math.Abs(a.Depth-b.Depth) > 0.05+0.05*a.Depth { + t.Fatalf("pair %d: depth %.3f flooded vs %.3f round-tripped", i, a.Depth, b.Depth) + } + } +} + +func TestSuppliedVolumeIsValidated(t *testing.T) { + mesh := openSpaceMesh(t) + pairs := []SightlinePair{{From: Point{X: -500}, To: Point{X: 500}}} + cases := []struct { + name string + vol EventSmokeVolume + }{ + {"no dims", EventSmokeVolume{VoxelSize: 16}}, + {"no voxel size", EventSmokeVolume{DimX: 2, DimY: 2, DimZ: 2}}, + {"density not base64", EventSmokeVolume{DimX: 2, DimY: 2, DimZ: 2, VoxelSize: 16, Density: "!!!"}}, + {"density too short", EventSmokeVolume{DimX: 2, DimY: 2, DimZ: 2, VoxelSize: 16, Density: "AA=="}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + vol := tc.vol + _, err := Sightlines(mesh, SightlineRequest{ + Map: "de_test", Smoke: &CloudSpec{Volume: &vol}, Pairs: pairs, + }) + if err == nil { + t.Fatal("expected the malformed volume to be rejected") + } + }) + } +} + +// Several clouds stack: a line through two thin ones is blocked even when +// neither would do it alone. +func TestSightlineSumsDepthAcrossClouds(t *testing.T) { + mesh := openSpaceMesh(t) + // Two clouds either side of the line's midpoint, each grazed near its rim. + a := Point{Y: -smokeRadius * 0.9} + b := Point{X: 300, Y: -smokeRadius * 0.9} + pairs := []SightlinePair{{From: Point{X: -300}, To: Point{X: 600}}} + + one, err := Sightlines(mesh, SightlineRequest{Map: "de_test", At: &a, Pairs: pairs}) + if err != nil { + t.Fatalf("sightlines: %v", err) + } + both, err := Sightlines(mesh, SightlineRequest{ + Map: "de_test", + Smoke: &CloudSpec{At: &a}, Smokes: []CloudSpec{{At: &b}}, + Pairs: pairs, + }) + if err != nil { + t.Fatalf("sightlines: %v", err) + } + if len(both.Results[0].PerSmoke) != 2 { + t.Fatalf("expected a per-cloud split, got %+v", both.Results[0].PerSmoke) + } + sum := both.Results[0].PerSmoke[0] + both.Results[0].PerSmoke[1] + if math.Abs(sum-both.Results[0].Depth) > 1e-9 { + t.Fatalf("per-smoke depths %v do not sum to %v", both.Results[0].PerSmoke, both.Results[0].Depth) + } + if both.Results[0].Depth <= one.Results[0].Depth { + t.Fatalf("two clouds should put more smoke on the line than one: %.2f vs %.2f", + both.Results[0].Depth, one.Results[0].Depth) + } +} + +// A sealed point still has to answer something, and the answer has to say it is +// a guess. +func TestSightlineFallsBackToASphereWhenSealed(t *testing.T) { + mesh := meshFromBlob(t, sealedBoxTriBlob(24)) + at := Point{} + res, err := Sightlines(mesh, SightlineRequest{ + Map: "de_test", At: &at, + Pairs: []SightlinePair{{From: Point{X: -500, Z: 200}, To: Point{X: 500, Z: 200}}}, + }) + if err != nil { + t.Fatalf("sightlines: %v", err) + } + if len(res.Smokes) != 1 || !res.Smokes[0].Sealed || res.Smokes[0].Model != "sphere" { + t.Fatalf("a sealed cloud should be reported as a sphere fallback: %+v", res.Smokes) + } +} + +func TestSightlineRequestValidation(t *testing.T) { + mesh := openSpaceMesh(t) + at := Point{} + if _, err := Sightlines(nil, SightlineRequest{Map: "x", At: &at}); err != ErrNoMesh { + t.Fatal("a missing mesh should be ErrNoMesh") + } + if _, err := Sightlines(mesh, SightlineRequest{Map: "x", At: &at}); err == nil { + t.Fatal("an empty pair list should be rejected") + } + tooMany := make([]SightlinePair, maxSightlinePairs+1) + if _, err := Sightlines(mesh, SightlineRequest{Map: "x", At: &at, Pairs: tooMany}); err == nil { + t.Fatal("a pair list over the cap should be rejected") + } + if _, err := Sightlines(mesh, SightlineRequest{ + Map: "x", Smoke: &CloudSpec{}, Pairs: []SightlinePair{{}}, + }); err == nil { + t.Fatal("a cloud with neither a point nor a volume should be rejected") + } + if _, err := Sightlines(mesh, SightlineRequest{ + Map: "x", At: &at, + Pairs: []SightlinePair{{From: Point{X: math.Inf(1)}}}, + }); err == nil { + t.Fatal("non-finite pair coordinates should be rejected") + } +} + +// Asking about no smoke at all is legitimate: it is the pure map question, and +// it must not be answered as "everything is blocked". +func TestSightlineWithNoCloudsIsAWorldQuery(t *testing.T) { + mesh := meshFromBlob(t, bigWallTriBlob()) + res, err := Sightlines(mesh, SightlineRequest{ + Map: "de_test", + Pairs: []SightlinePair{ + {From: Point{X: -300}, To: Point{X: 300}}, + {From: Point{X: -300}, To: Point{X: -100}}, + }, + }) + if err != nil { + t.Fatalf("sightlines: %v", err) + } + if !res.Results[0].Blocked || res.Results[0].BlockedBy != "world" { + t.Fatalf("the wall should block the crossing line: %+v", res.Results[0]) + } + if res.Results[1].Blocked { + t.Fatalf("a line on one side of the wall is open: %+v", res.Results[1]) + } +} + +// sealedBoxTriBlob returns a closed axis-aligned box centred on the origin, +// small enough that a smoke at the origin has nowhere to go. +func sealedBoxTriBlob(half float64) []byte { + quad := func(a, b, c, d r3.Vector) []byte { + return triBlob([3]r3.Vector{a, b, c}, [3]r3.Vector{a, c, d}) + } + h := half + // Corners, low then high. + l := [8]r3.Vector{ + {X: -h, Y: -h, Z: -h}, {X: h, Y: -h, Z: -h}, {X: h, Y: h, Z: -h}, {X: -h, Y: h, Z: -h}, + {X: -h, Y: -h, Z: h}, {X: h, Y: -h, Z: h}, {X: h, Y: h, Z: h}, {X: -h, Y: h, Z: h}, + } + var blob []byte + for _, face := range [][]byte{ + quad(l[0], l[1], l[2], l[3]), + quad(l[4], l[5], l[6], l[7]), + quad(l[0], l[1], l[5], l[4]), + quad(l[3], l[2], l[6], l[7]), + quad(l[0], l[3], l[7], l[4]), + quad(l[1], l[2], l[6], l[5]), + } { + blob = append(blob, face...) + } + return blob +} diff --git a/internal/parser/oneway.go b/internal/parser/oneway.go new file mode 100644 index 0000000..f55edc1 --- /dev/null +++ b/internal/parser/oneway.go @@ -0,0 +1,354 @@ +package parser + +import ( + "errors" + "fmt" + "math" + + "github.com/5stackgg/demo-parser/internal/geometry" + "github.com/golang/geo/r3" +) + +// One-way detection. +// +// The thing to understand first: an eye-to-eye sightline cannot be one-way. The +// optical depth along a segment is the same integral in both directions, and +// the map occludes a segment or it does not, so "A's eyes see B's eyes" and "B's +// eyes see A's eyes" are the same question asked twice. +// +// A real one-way is therefore never about the line between two eyes. It comes +// from the two sides not being the same shape: +// +// - Stance. Crouching drops an eye 18 units. If the cloud has a gap under it +// or a lip over it, that is the difference between looking through smoke and +// looking under it — and the same drop applied to the other player moves +// them out of view rather than into it. +// - Body extent. Seeing someone means seeing any part of them, and the ray to +// their knees is not the ray from their knees to you. A player whose head +// alone clears the cloud is visible while seeing nothing. +// +// So the test is run over both stances on both sides, in both directions, with +// the target treated as a body rather than a point. What comes back is honest +// about resting on assumed eye heights and on a density model that knows +// nothing about how CS2 lights a cloud. + +// CS2 view offsets above the player's feet. These are the engine's long- +// standing values (the same 64/46 split CS:GO shipped), not something measured +// off a demo here — a demo would give them exactly, as PositionEyes() minus +// Position(), and calibrating against one is the obvious next step. An error +// here shifts an eye by at most a unit or two, which matters only for a line +// already grazing the edge of a cloud — which is exactly the case one-ways live +// in, so treat marginal calls as marginal. +const ( + standEyeHeight = 64.09 + crouchEyeHeight = 46.08 +) + +const ( + stanceStand = "stand" + stanceCrouch = "crouch" +) + +// OneWayRequest tests pairs of player positions for asymmetric visibility. +type OneWayRequest struct { + Map string `json:"map"` + Smokes []CloudSpec `json:"smokes,omitempty"` + Smoke *CloudSpec `json:"smoke,omitempty"` + At *Point `json:"at,omitempty"` + // Pairs are two players' positions. From is side A, To is side B. + Pairs []SightlinePair `json:"pairs"` + // Positions says what the pair's Z means: "feet" (default, a standing + // position as the game reports it) or "eyes", in which case the standing + // eye height is subtracted to recover the feet. Getting this wrong moves + // every eye by 64 units, so it is explicit rather than guessed. + Positions string `json:"positions,omitempty"` + Threshold *float64 `json:"threshold,omitempty"` + // Eye-height overrides, for callers who have measured better numbers than + // the constants above. + StandEyeHeight *float64 `json:"stand_eye_height,omitempty"` + CrouchEyeHeight *float64 `json:"crouch_eye_height,omitempty"` +} + +func (r OneWayRequest) sightlineRequest() SightlineRequest { + return SightlineRequest{Smokes: r.Smokes, Smoke: r.Smoke, At: r.At} +} + +func (r OneWayRequest) threshold() float64 { + if r.Threshold == nil || *r.Threshold <= 0 { + return DefaultBlockThreshold + } + return *r.Threshold +} + +func (r OneWayRequest) eyeHeights() (stand, crouch float64) { + stand, crouch = standEyeHeight, crouchEyeHeight + if r.StandEyeHeight != nil && *r.StandEyeHeight > 0 { + stand = *r.StandEyeHeight + } + if r.CrouchEyeHeight != nil && *r.CrouchEyeHeight > 0 { + crouch = *r.CrouchEyeHeight + } + return stand, crouch +} + +// OneWayView is what one player can see of the other in one stance pairing. +type OneWayView struct { + Visible bool `json:"visible"` + // Depth is the smoke on the clearest line to any part of the target, in + // cell widths of full density; Transmittance is e^-Depth. When every part + // of the target is behind the map, Depth describes the eye-to-eye line and + // WorldBlocked says the map decided it. + Depth float64 `json:"depth"` + Transmittance float64 `json:"transmittance"` + WorldBlocked bool `json:"world_blocked"` + // SamplesVisible of Samples body points were both clear of the map and + // under the threshold. One visible sample out of seven is a sliver of a + // shoulder, which is why this is reported rather than folded into Visible. + SamplesVisible int `json:"samples_visible"` + Samples int `json:"samples"` +} + +// OneWayStance is one (A stance, B stance) pairing, tested both ways. +type OneWayStance struct { + AStance string `json:"a_stance"` + BStance string `json:"b_stance"` + AToB OneWayView `json:"a_to_b"` + BToA OneWayView `json:"b_to_a"` + OneWay bool `json:"one_way"` + // Favors names the side that can see while the other cannot. + Favors string `json:"favors,omitempty"` + // Cause is "smoke" when the blind side is stopped by the cloud, "world" + // when the map alone does it (a ledge, not a lineup), and empty when the + // pairing is symmetric. + Cause string `json:"cause,omitempty"` + // Margin is how much room the call has: the smaller of how far the seeing + // side sits under the threshold and how far the blind side sits over it. + // Zero or less means the answer would flip on a small change of threshold, + // and a world-caused verdict reports zero because a wall is not a matter of + // degree. + Margin float64 `json:"margin"` +} + +// OneWayResult is one pair's verdict across every stance pairing. +type OneWayResult struct { + OneWay bool `json:"one_way"` + Favors string `json:"favors,omitempty"` + Cause string `json:"cause,omitempty"` + // Confidence is "none" when nothing is asymmetric, then "marginal", + // "likely" or "strong" as the margin grows. It grades the geometry only — + // see Caveats. + Confidence string `json:"confidence"` + // Contested marks the case where different stance pairings favour + // different sides — typically "whoever crouches wins". The advantage then + // belongs to whoever picks the right stance rather than to a position, so + // Favors alone would be misleading. + Contested bool `json:"contested,omitempty"` + // Best is the stance pairing with the widest margin, i.e. the one to + // actually stand in. Nil when no pairing is one-way. + Best *OneWayStance `json:"best,omitempty"` + Stances []OneWayStance `json:"stances"` +} + +type OneWayResponse struct { + Map string `json:"map"` + Threshold float64 `json:"threshold"` + Smokes []CloudInfo `json:"smokes"` + Results []OneWayResult `json:"results"` + // Caveats is returned on every response, not only on marginal ones. This + // is a geometric model and the thing it is modelling is partly a renderer. + Caveats []string `json:"caveats"` +} + +// oneWayCaveats are the limits of this model, stated on every response so a UI +// can put them in front of whoever is about to trust one. +var oneWayCaveats = []string{ + "eye heights are the engine's standing/crouching view offsets, not measured per map or per stance transition", + "CS2 lights smoke volumetrically and a target's contrast against its background is not modelled, so a cloud can be one-way in game while this reports it symmetric", + "eye-to-eye is symmetric by construction: every asymmetry here comes from stance or from which parts of a body each side can see", + "a marginal verdict rests on the density threshold rather than on the geometry, and should be treated as unverified", +} + +// Margins, in optical depth, for grading a verdict. One unit is a factor of e +// in how much of the target survives, so a full unit of clearance on both sides +// means the call does not hinge on the exact threshold. These are chosen, not +// measured. +const ( + strongMargin = 1.5 + likelyMargin = 0.5 +) + +// OneWay tests each pair for asymmetric visibility through the supplied clouds. +func OneWay(mesh *geometry.Mesh, req OneWayRequest) (OneWayResponse, error) { + if mesh == nil { + return OneWayResponse{}, ErrNoMesh + } + if len(req.Pairs) == 0 { + return OneWayResponse{}, errors.New("pairs must not be empty") + } + if len(req.Pairs) > maxSightlinePairs { + return OneWayResponse{}, fmt.Errorf("too many pairs: %d (max %d)", len(req.Pairs), maxSightlinePairs) + } + switch req.Positions { + case "", "feet", "eyes": + default: + return OneWayResponse{}, fmt.Errorf("positions must be \"feet\" or \"eyes\", got %q", req.Positions) + } + clouds, infos, err := resolveClouds(mesh, req.sightlineRequest().clouds()) + if err != nil { + return OneWayResponse{}, err + } + + stand, crouch := req.eyeHeights() + threshold := req.threshold() + out := OneWayResponse{ + Map: req.Map, + Threshold: threshold, + Smokes: infos, + Caveats: oneWayCaveats, + } + for i, pair := range req.Pairs { + if !pair.From.valid() || !pair.To.valid() { + return OneWayResponse{}, fmt.Errorf("pair %d: coordinates must be finite", i) + } + a, b := pair.From.vec(), pair.To.vec() + if req.Positions == "eyes" { + a.Z -= stand + b.Z -= stand + } + out.Results = append(out.Results, oneWayPair(mesh, clouds, a, b, stand, crouch, threshold)) + } + return out, nil +} + +func oneWayPair(mesh *geometry.Mesh, clouds []resolvedCloud, aFeet, bFeet r3.Vector, stand, crouch, threshold float64) OneWayResult { + height := map[string]float64{stanceStand: stand, stanceCrouch: crouch} + res := OneWayResult{Confidence: "none"} + for _, as := range [2]string{stanceStand, stanceCrouch} { + for _, bs := range [2]string{stanceStand, stanceCrouch} { + aEye := raise(aFeet, height[as]) + bEye := raise(bFeet, height[bs]) + st := OneWayStance{ + AStance: as, + BStance: bs, + AToB: look(mesh, clouds, aEye, bEye, bFeet, threshold), + BToA: look(mesh, clouds, bEye, aEye, aFeet, threshold), + } + if st.AToB.Visible != st.BToA.Visible { + seeing, blind := st.AToB, st.BToA + st.OneWay, st.Favors = true, "a" + if st.BToA.Visible { + seeing, blind = st.BToA, st.AToB + st.Favors = "b" + } + if blind.WorldBlocked { + // The blind side is behind the map, so its depth says + // nothing about how safe the call is and there is no + // threshold for the verdict to be sensitive to. + st.Cause, st.Margin = "world", 0 + } else { + st.Cause = "smoke" + st.Margin = math.Min(threshold-seeing.Depth, blind.Depth-threshold) + } + } + res.Stances = append(res.Stances, st) + } + } + + best := -1 + favored := map[string]bool{} + for i, st := range res.Stances { + if !st.OneWay { + continue + } + favored[st.Favors] = true + if best < 0 || better(st, res.Stances[best]) { + best = i + } + } + if best < 0 { + return res + } + st := res.Stances[best] + res.OneWay, res.Favors, res.Cause = true, st.Favors, st.Cause + res.Contested = len(favored) > 1 + res.Best = &res.Stances[best] + res.Confidence = grade(st) + return res +} + +// better ranks one one-way pairing above another: the wider margin wins, and +// where there is no margin to compare — a world-caused verdict, or two pairings +// equally clear of the threshold — the one exposing more of the target does. +func better(a, b OneWayStance) bool { + if a.Margin != b.Margin { + return a.Margin > b.Margin + } + return seeingView(a).SamplesVisible > seeingView(b).SamplesVisible +} + +func seeingView(st OneWayStance) OneWayView { + if st.Favors == "b" { + return st.BToA + } + return st.AToB +} + +// grade turns a margin into a word. Sliver visibility is capped at "marginal" +// however wide the margin looks: when a single body sample carries the seeing +// side, the verdict is really a statement about where this model put that +// sample, not about the smoke. +func grade(st OneWayStance) string { + seeing := seeingView(st) + switch { + case seeing.SamplesVisible <= 1: + return "marginal" + case st.Cause == "world": + // The map either occludes or it does not. There is no threshold for + // the verdict to be sensitive to, only the fidelity of the mesh. + return "strong" + case st.Margin >= strongMargin: + return "strong" + case st.Margin >= likelyMargin: + return "likely" + default: + return "marginal" + } +} + +func raise(feet r3.Vector, h float64) r3.Vector { + return r3.Vector{X: feet.X, Y: feet.Y, Z: feet.Z + h} +} + +// look reports what an observer at eye can see of a player with eyes at +// targetEye standing on targetFeet: the clearest line to any part of them. +func look(mesh *geometry.Mesh, clouds []resolvedCloud, eye, targetEye, targetFeet r3.Vector, threshold float64) OneWayView { + pts := append([]r3.Vector{targetEye}, bodySamplePoints(eye, targetEye, targetFeet)...) + view := OneWayView{Samples: len(pts), Depth: math.Inf(1), WorldBlocked: true} + for _, p := range pts { + if mesh.Occluded(eye, p) { + continue + } + view.WorldBlocked = false + depth := 0.0 + for _, c := range clouds { + depth += c.depth(eye, p) + } + if depth < view.Depth { + view.Depth = depth + } + if depth < threshold { + view.SamplesVisible++ + } + } + if view.WorldBlocked { + // Nothing to report a depth for; describe the eye-to-eye line so the + // number still means something to a caller comparing directions. + view.Depth = 0 + for _, c := range clouds { + view.Depth += c.depth(eye, targetEye) + } + } + view.Visible = view.SamplesVisible > 0 + view.Transmittance = math.Exp(-view.Depth) + return view +} diff --git a/internal/parser/oneway_test.go b/internal/parser/oneway_test.go new file mode 100644 index 0000000..33be9b3 --- /dev/null +++ b/internal/parser/oneway_test.go @@ -0,0 +1,276 @@ +package parser + +import ( + "math" + "testing" + + "github.com/5stackgg/demo-parser/internal/geometry" +) + +// The premise the whole one-way test rests on: the line between two eyes is the +// same line whichever end you start from, so any endpoint that only ever +// compares eye to eye can never find an asymmetry. +func TestEyeToEyeIsSymmetric(t *testing.T) { + mesh := openSpaceMesh(t) + at := Point{Z: 120} + a, b := Point{Z: 64}, Point{X: -500, Z: 64} + res, err := Sightlines(mesh, SightlineRequest{ + Map: "de_test", At: &at, + Pairs: []SightlinePair{{From: a, To: b}, {From: b, To: a}}, + }) + if err != nil { + t.Fatalf("sightlines: %v", err) + } + // Not bit-identical: the voxel walk enters the grid from a different + // corner each way round, so the sum lands in a different order. + if math.Abs(res.Results[0].Depth-res.Results[1].Depth) > 1e-9 { + t.Fatalf("depth is direction-dependent: %v vs %v", res.Results[0].Depth, res.Results[1].Depth) + } + if res.Results[0].Blocked != res.Results[1].Blocked { + t.Fatal("a single line cannot be blocked one way and not the other") + } +} + +// oneWayHeadInCloud is the classic: a cloud sitting high enough that a standing +// player's eyes are inside its lower half while their legs are below it. They +// see nothing; the player across the way sees their legs and shoots them. It is +// also the case crouching solves, which is what the stance sweep is for. +// +// Modelled here as a smoke resting 120 units up — on a box, a rail, a ledge — +// with player A standing directly under it and player B out in clear air. +func oneWayHeadInCloud(t *testing.T) (*geometry.Mesh, OneWayRequest) { + t.Helper() + mesh := openSpaceMesh(t) + at := Point{Z: 120} + return mesh, OneWayRequest{ + Map: "de_test", + At: &at, + Pairs: []SightlinePair{{From: Point{}, To: Point{X: -500}}}, + } +} + +func TestOneWayFindsHeadInCloud(t *testing.T) { + mesh, req := oneWayHeadInCloud(t) + res, err := OneWay(mesh, req) + if err != nil { + t.Fatalf("oneway: %v", err) + } + got := res.Results[0] + if !got.OneWay { + t.Fatalf("expected an asymmetry: %+v", got) + } + if got.Favors != "b" { + t.Fatalf("the player outside the cloud should be the one who can see, got %q", got.Favors) + } + if got.Cause != "smoke" { + t.Fatalf("cause = %q, want smoke", got.Cause) + } + if got.Confidence == "none" || got.Best == nil { + t.Fatalf("a one-way verdict needs a grade and a stance to stand in: %+v", got) + } + if len(res.Caveats) == 0 { + t.Fatal("the caveats are part of the contract; they are always returned") + } + if len(got.Stances) != 4 { + t.Fatalf("expected both stances on both sides, got %d combinations", len(got.Stances)) + } +} + +// Crouching is the counter, and the model has to show it: the same pair is +// one-way while A stands and symmetric once A drops under the cloud. +func TestOneWayDependsOnStance(t *testing.T) { + mesh, req := oneWayHeadInCloud(t) + res, err := OneWay(mesh, req) + if err != nil { + t.Fatalf("oneway: %v", err) + } + byStance := map[[2]string]OneWayStance{} + for _, st := range res.Results[0].Stances { + byStance[[2]string{st.AStance, st.BStance}] = st + } + + standing := byStance[[2]string{stanceStand, stanceStand}] + if !standing.OneWay || standing.AToB.Visible || !standing.BToA.Visible { + t.Fatalf("standing under the cloud should be blind while being seen: %+v", standing) + } + if standing.Margin <= 0 { + t.Fatalf("a one-way with no margin is a coin flip: %+v", standing) + } + + crouched := byStance[[2]string{stanceCrouch, stanceStand}] + if crouched.OneWay { + t.Fatalf("crouching under the cloud should even the fight up: %+v", crouched) + } + if !crouched.AToB.Visible { + t.Fatalf("crouching should get A's eyes under the cloud: %+v", crouched.AToB) + } + if crouched.AToB.Depth >= standing.AToB.Depth { + t.Fatalf("crouching should put less smoke on the line, got %.2f crouched vs %.2f standing", + crouched.AToB.Depth, standing.AToB.Depth) + } +} + +// A pair with clear air between them is not a one-way, and must not be dressed +// up as one. +func TestOneWayReportsSymmetryHonestly(t *testing.T) { + mesh := openSpaceMesh(t) + res, err := OneWay(mesh, OneWayRequest{ + Map: "de_test", + Pairs: []SightlinePair{{From: Point{}, To: Point{X: -500}}}, + }) + if err != nil { + t.Fatalf("oneway: %v", err) + } + got := res.Results[0] + if got.OneWay || got.Favors != "" || got.Best != nil { + t.Fatalf("open ground is not a one-way: %+v", got) + } + if got.Confidence != "none" { + t.Fatalf("confidence = %q, want none", got.Confidence) + } + for _, st := range got.Stances { + if !st.AToB.Visible || !st.BToA.Visible { + t.Fatalf("both players should see each other in every stance: %+v", st) + } + if st.AToB.SamplesVisible != st.AToB.Samples { + t.Fatalf("every body sample should be visible in the open: %+v", st.AToB) + } + } +} + +// The map can produce the same asymmetry on its own — crouching to see under a +// gap the standing player cannot see back through. That is a property of the +// ledge, not of anyone's lineup, so it is labelled as such. It is also the case +// where the advantage belongs to whoever crouches rather than to either +// position, which the verdict has to say out loud. +func TestOneWayAttributesAGapToTheWorld(t *testing.T) { + // A wall hanging from z=58 upward, i.e. a gap along the ground, with B + // standing on a 34-unit step on the far side of it. + mesh := meshFromBlob(t, quadTriBlob(-400, 400, 58, 400)) + res, err := OneWay(mesh, OneWayRequest{ + Map: "de_test", + Pairs: []SightlinePair{{From: Point{X: -100}, To: Point{X: 100, Z: 34}}}, + }) + if err != nil { + t.Fatalf("oneway: %v", err) + } + got := res.Results[0] + if !got.OneWay { + t.Fatalf("crouching under the gap should beat standing over it: %+v", got) + } + if got.Cause != "world" { + t.Fatalf("cause = %q, want world — there is no smoke in this scene", got.Cause) + } + if !got.Contested { + t.Fatalf("whoever crouches wins here, so the verdict is contested: %+v", got) + } + if got.Best.Margin != 0 { + t.Fatalf("a world-caused verdict has no depth margin to report: %+v", got.Best) + } + if !seeingView(*got.Best).Visible || seeingView(*got.Best).WorldBlocked { + t.Fatalf("the favoured side sees under the gap: %+v", got.Best) + } + // Both crouched, the gap is mutual — the geometry is reciprocal, and the + // model must not invent an advantage that stance alone removes. + for _, st := range got.Stances { + if st.AStance == stanceCrouch && st.BStance == stanceCrouch && st.OneWay { + t.Fatalf("with both players crouched the gap works both ways: %+v", st) + } + } +} + +// "eyes" positions are the same query with the feet worked back out, so the two +// forms have to agree — a caller getting this wrong would move every eye by 64 +// units and never know. +func TestOneWayPositionsModes(t *testing.T) { + mesh, req := oneWayHeadInCloud(t) + feet, err := OneWay(mesh, req) + if err != nil { + t.Fatalf("oneway: %v", err) + } + asEyes := req + asEyes.Positions = "eyes" + asEyes.Pairs = []SightlinePair{{ + From: Point{Z: standEyeHeight}, + To: Point{X: -500, Z: standEyeHeight}, + }} + eyes, err := OneWay(mesh, asEyes) + if err != nil { + t.Fatalf("oneway: %v", err) + } + if feet.Results[0].Favors != eyes.Results[0].Favors { + t.Fatalf("feet and eye positions disagree: %q vs %q", + feet.Results[0].Favors, eyes.Results[0].Favors) + } + for i := range feet.Results[0].Stances { + a, b := feet.Results[0].Stances[i], eyes.Results[0].Stances[i] + if math.Abs(a.AToB.Depth-b.AToB.Depth) > 1e-9 { + t.Fatalf("stance %d: depth %v vs %v", i, a.AToB.Depth, b.AToB.Depth) + } + } + + bad := req + bad.Positions = "shoulders" + if _, err := OneWay(mesh, bad); err == nil { + t.Fatal("an unknown positions mode should be rejected rather than guessed") + } +} + +func TestOneWayEyeHeightsAreOverridable(t *testing.T) { + mesh, req := oneWayHeadInCloud(t) + // Put both "stances" at the same height: the stance sweep then has nothing + // left to vary and the pairing must come out symmetric in the same way for + // all four combinations. + same := 64.0 + req.StandEyeHeight, req.CrouchEyeHeight = &same, &same + res, err := OneWay(mesh, req) + if err != nil { + t.Fatalf("oneway: %v", err) + } + first := res.Results[0].Stances[0] + for _, st := range res.Results[0].Stances[1:] { + if st.AToB.Depth != first.AToB.Depth || st.BToA.Depth != first.BToA.Depth { + t.Fatalf("with one eye height every stance is the same query: %+v vs %+v", st, first) + } + } +} + +func TestOneWayRequestValidation(t *testing.T) { + mesh := openSpaceMesh(t) + if _, err := OneWay(nil, OneWayRequest{Map: "x", Pairs: []SightlinePair{{}}}); err != ErrNoMesh { + t.Fatal("a missing mesh should be ErrNoMesh") + } + if _, err := OneWay(mesh, OneWayRequest{Map: "x"}); err == nil { + t.Fatal("an empty pair list should be rejected") + } + if _, err := OneWay(mesh, OneWayRequest{ + Map: "x", + Pairs: []SightlinePair{{From: Point{X: math.NaN()}}}, + }); err == nil { + t.Fatal("non-finite coordinates should be rejected") + } +} + +// A sliver of a shoulder is not a confident read, however far the depths are +// apart: it says more about where this model put its body samples than about +// the smoke. +func TestOneWayGradeCapsSliverVisibility(t *testing.T) { + sliver := OneWayStance{ + Favors: "a", + AToB: OneWayView{Visible: true, SamplesVisible: 1, Samples: 7}, + Margin: 10, + } + if got := grade(sliver); got != "marginal" { + t.Fatalf("grade = %q, want marginal for a single visible sample", got) + } + solid := sliver + solid.AToB.SamplesVisible = 4 + if got := grade(solid); got != "strong" { + t.Fatalf("grade = %q, want strong for a wide margin on a visible body", got) + } + narrow := solid + narrow.Margin = 0.2 + if got := grade(narrow); got != "marginal" { + t.Fatalf("grade = %q, want marginal for a margin inside the noise", got) + } +} diff --git a/internal/parser/parser.go b/internal/parser/parser.go index b77e8d4..af21d00 100644 --- a/internal/parser/parser.go +++ b/internal/parser/parser.go @@ -143,6 +143,14 @@ type state struct { // per-tick FrameDone events down to ~4Hz for the 2D replay table. lastPositionSampleTick int + // Full-rate position rows for the last throwBurstTicks ticks, indexed by + // tick modulo its length, so a grenade throw can emit the ticks that came + // before it. burstUntilTick is the last tick of the window ahead of the + // most recent throw. + posRing [throwBurstTicks + 1]positionSample + burstUntilTick int + positionBursts int + // Grenade projectile last-known positions, keyed by entity id. // demoinfocs' GrenadeEvent.Position is stale or zeroed for some // CS2 demos; tracking the projectile entity's own Position() each @@ -257,6 +265,7 @@ func (s *state) registerHandlers() { // come from packets observed during ParseToEnd, so this runs even on // partial parses. func (s *state) finalize() { + s.res.SchemaVersion = SchemaVersion if rate := s.parser.TickRate(); rate > 0 { s.res.TickRate = rate } @@ -339,6 +348,11 @@ func (s *state) finalize() { s.computeTrades() + if s.sortPositions() { + fmt.Fprintf(os.Stderr, "[positions] rows=%d throw_bursts=%d\n", + len(s.res.Positions), s.positionBursts) + } + gids := make([]int, 0, len(s.grenadePaths)) for gid := range s.grenadePaths { gids = append(gids, gid) diff --git a/internal/parser/positions_test.go b/internal/parser/positions_test.go new file mode 100644 index 0000000..26cf9b7 --- /dev/null +++ b/internal/parser/positions_test.go @@ -0,0 +1,216 @@ +package parser + +import ( + "encoding/json" + "sort" + "strings" + "testing" +) + +// driveTicks runs the emit decision onFrameDone makes, over a range of ticks, +// with a grenade thrown at each of throwAt. One row per tick stands in for the +// per-player rows the real capture builds off the parser. +// +// The throw is dispatched before the frame it lands in, which is the order +// demoinfocs uses: game events fire while the frame is being parsed and +// FrameDone closes it. +func driveTicks(s *state, from, to, sampleEvery int, throwAt ...int) { + throws := map[int]bool{} + for _, t := range throwAt { + throws[t] = true + } + for tick := from; tick <= to; tick++ { + if throws[tick] { + s.burstPositions(tick) + } + due := s.positionSampleDue(tick, sampleEvery) + slot := s.stagePositionSlot(tick) + slot.rows = append(slot.rows, EventPosition{Tick: tick, AttackerSteamID: "p"}) + if due || tick <= s.burstUntilTick { + s.emitPositions(slot) + } + } +} + +func emittedTicks(s *state) []int { + out := make([]int, 0, len(s.res.Positions)) + for _, p := range s.res.Positions { + out = append(out, p.Tick) + } + return out +} + +func ticksBetween(lo, hi int) map[int]bool { + set := map[int]bool{} + for t := lo; t <= hi; t++ { + set[t] = true + } + return set +} + +// The window either side of a throw is what makes a mined lineup reproducible: +// the ~4Hz timeline continues, and every tick within ten of the release is +// emitted on top of it. +func TestPositionBurstCoversTheThrowWindow(t *testing.T) { + s := &state{res: &Result{}, liveRound: true} + driveTicks(s, 100, 170, 16, 130) + + want := ticksBetween(130-throwBurstTicks, 130+throwBurstTicks) + // The ~4Hz clock keeps its own phase through the burst. + for _, t := range []int{100, 116, 148, 164} { + want[t] = true + } + + got := emittedTicks(s) + seen := map[int]int{} + for _, tick := range got { + seen[tick]++ + } + for tick := range want { + if seen[tick] != 1 { + t.Fatalf("tick %d emitted %d times, want exactly once (got %v)", tick, seen[tick], got) + } + } + for tick, n := range seen { + if !want[tick] { + t.Fatalf("tick %d emitted %d times but is neither due nor in the throw window", tick, n) + } + } +} + +// Two throws close together share one run of full-rate ticks. Nothing may be +// emitted twice: the API writes these rows straight into a table. +func TestOverlappingThrowsDoNotDuplicateRows(t *testing.T) { + s := &state{res: &Result{}, liveRound: true} + driveTicks(s, 100, 170, 16, 130, 135) + + seen := map[int]int{} + for _, tick := range emittedTicks(s) { + seen[tick]++ + if seen[tick] > 1 { + t.Fatalf("tick %d emitted twice", tick) + } + } + for tick := 120; tick <= 145; tick++ { + if seen[tick] != 1 { + t.Fatalf("tick %d should be inside the merged window, got %d rows", tick, seen[tick]) + } + } +} + +// A throw in the first moments of a round has less history than the window +// asks for. It emits what the ring still holds rather than inventing ticks. +func TestThrowNearRoundStartEmitsWhatItHas(t *testing.T) { + s := &state{res: &Result{}, liveRound: true} + driveTicks(s, 200, 260, 16, 203) + + for _, tick := range emittedTicks(s) { + if tick < 200 { + t.Fatalf("emitted tick %d from before the round started", tick) + } + } + for tick := 200; tick <= 213; tick++ { + if !containsTick(s, tick) { + t.Fatalf("tick %d is inside the window and should have been emitted", tick) + } + } +} + +// The ring only holds the window it promises. A throw cannot reach further back +// than that, and must not read a stale slot as if it were recent. +func TestBurstIgnoresTicksThatRolledOutOfTheRing(t *testing.T) { + s := &state{res: &Result{}, liveRound: true} + // One tick staged a long time ago, then a gap, then the throw. + slot := s.stagePositionSlot(50) + slot.rows = append(slot.rows, EventPosition{Tick: 50}) + driveTicks(s, 300, 320, 16, 310) + + for _, tick := range emittedTicks(s) { + if tick == 50 { + t.Fatal("a slot that rolled out of the ring was emitted as if it were in the window") + } + } +} + +// Nothing is emitted outside a live round, throw or not: the replay viewer skips +// freezetime and the walkaround, so those rows are pure payload. +func TestBurstStaysOutOfDeadTime(t *testing.T) { + s := &state{res: &Result{}, liveRound: false} + s.burstPositions(130) + if len(s.res.Positions) != 0 || s.burstUntilTick != 0 { + t.Fatalf("a throw outside a live round should emit nothing: %d rows, window to %d", + len(s.res.Positions), s.burstUntilTick) + } +} + +// Bursts append ticks from behind the write head, so the array has to be put +// back in order before it goes out. +func TestPositionsEndUpInTickOrder(t *testing.T) { + s := &state{res: &Result{}, liveRound: true} + // Thrown a few ticks after a due sample, so the window reaches back past + // rows already written. A throw that lands more than a window after the + // last due sample appends in order and would not exercise this. + driveTicks(s, 100, 170, 16, 122) + + if sort.SliceIsSorted(s.res.Positions, func(i, j int) bool { + return s.res.Positions[i].Tick < s.res.Positions[j].Tick + }) { + t.Fatal("expected the burst to have put the array out of order; the sort would be untested") + } + if !s.sortPositions() { + t.Fatal("sortPositions should report that it sorted") + } + for i := 1; i < len(s.res.Positions); i++ { + if s.res.Positions[i-1].Tick > s.res.Positions[i].Tick { + t.Fatalf("positions are out of order at %d: %d then %d", + i, s.res.Positions[i-1].Tick, s.res.Positions[i].Tick) + } + } +} + +func TestSortPositionsIsANoOpWithoutBursts(t *testing.T) { + s := &state{res: &Result{}, liveRound: true} + driveTicks(s, 100, 170, 16) + if s.sortPositions() { + t.Fatal("a parse with no throws should not touch the array") + } +} + +// Wire contract: the API and the web replay read these keys off the blob. +func TestBlobCarriesCrouchStateAndSchemaVersion(t *testing.T) { + row, err := json.Marshal(EventPosition{Tick: 1, Ducked: true}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if !strings.Contains(string(row), `"ducked":true`) { + t.Fatalf("crouch state is missing from the wire form: %s", row) + } + // Absent when standing, so the common row does not grow. + row, _ = json.Marshal(EventPosition{Tick: 1}) + if strings.Contains(string(row), "ducked") { + t.Fatalf("a standing row should not carry the flag: %s", row) + } + + res, err := json.Marshal(&Result{SchemaVersion: SchemaVersion}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if !strings.Contains(string(res), `"schema_version":2`) { + t.Fatalf("schema version is missing from the blob: %s", res) + } + // Emitted even at zero, so a consumer can tell an old blob from a field it + // forgot to read. + old, _ := json.Marshal(&Result{}) + if !strings.Contains(string(old), `"schema_version":0`) { + t.Fatalf("schema version must not be omitted at zero: %s", old) + } +} + +func containsTick(s *state, tick int) bool { + for _, p := range s.res.Positions { + if p.Tick == tick { + return true + } + } + return false +} diff --git a/internal/parser/shots.go b/internal/parser/shots.go index 15c48b7..a5a3e69 100644 --- a/internal/parser/shots.go +++ b/internal/parser/shots.go @@ -2,6 +2,7 @@ package parser import ( "math" + "sort" "github.com/markus-wa/demoinfocs-golang/v5/pkg/demoinfocs/common" "github.com/markus-wa/demoinfocs-golang/v5/pkg/demoinfocs/events" @@ -85,15 +86,68 @@ func (s *state) onFrameDone(_ events.FrameDone) { if sampleEvery < 1 { sampleEvery = 1 } - if s.lastPositionSampleTick != 0 && curTick-s.lastPositionSampleTick < sampleEvery { - return - } - s.lastPositionSampleTick = curTick + // Asked before the liveRound gate below, so the ~4Hz clock keeps the phase + // it had before this window existed. + due := s.positionSampleDue(curTick, sampleEvery) // Skip freezetime + end-of-round walkaround — the replay viewer // auto-skips both, so persisting them is pure waste. if !s.liveRound { return } + // Every live tick is captured, whether or not it is due: a grenade thrown + // in the next few ticks needs the ones already behind it. + slot := s.capturePositions(curTick) + if due || curTick <= s.burstUntilTick { + s.emitPositions(slot) + } +} + +// throwBurstTicks is how far either side of a grenade throw positions are +// emitted at the demo's full tick rate instead of the usual ~4Hz. +// +// At 4Hz the nearest sample to a release can be 125ms stale, which is about 30 +// units of drift at run speed — enough to put a mined lineup's standing spot in +// the wrong place and its view angles on the wrong pixel. Ten ticks is 156ms at +// 64 tick and covers the whole run-up-and-release, at a cost of ~20 extra rows +// per player per throw. +const throwBurstTicks = 10 + +// positionSample is one tick's worth of position rows. Slots live in a short +// ring so a throw can emit the ticks that came before it, and their row slices +// are reused rather than reallocated every tick. +type positionSample struct { + tick int + // emitted guards against a row reaching Result.Positions twice, when a + // due 4Hz sample and a throw window land on the same tick. + emitted bool + rows []EventPosition +} + +// positionSampleDue reports whether the ~4Hz clock has come round, advancing it +// when it has. +func (s *state) positionSampleDue(tick, every int) bool { + if s.lastPositionSampleTick != 0 && tick-s.lastPositionSampleTick < every { + return false + } + s.lastPositionSampleTick = tick + return true +} + +// stagePositionSlot claims this tick's ring slot, dropping whatever tick was +// previously in it. +func (s *state) stagePositionSlot(tick int) *positionSample { + slot := &s.posRing[tick%len(s.posRing)] + slot.tick = tick + slot.emitted = false + slot.rows = slot.rows[:0] + return slot +} + +// capturePositions fills this tick's ring slot with a row per playing player +// and returns it. Nothing is emitted here. +func (s *state) capturePositions(tick int) *positionSample { + slot := s.stagePositionSlot(tick) + // Bomb carrier this sample tick, if any. Match by SteamID rather // than pointer — the carrier pointer is generally stable across // frames in v5, but some demos churn the participants slice and @@ -111,8 +165,8 @@ func (s *state) onFrameDone(_ events.FrameDone) { continue } pos := p.Position() - s.res.Positions = append(s.res.Positions, EventPosition{ - Tick: curTick, + slot.rows = append(slot.rows, EventPosition{ + Tick: tick, Round: s.currentRound, AttackerSteamID: sid, Team: teamCode(p.Team), @@ -128,8 +182,58 @@ func (s *state) onFrameDone(_ events.FrameDone) { HasBomb: carrierSID != "" && sid == carrierSID, HasDefuser: p.Team == common.TeamCounterTerrorists && p.HasDefuseKit(), ActiveWeapon: activeWeaponName(p), + Ducked: p.IsDucking(), }) } + return slot +} + +func (s *state) emitPositions(slot *positionSample) { + if slot == nil || slot.emitted { + return + } + slot.emitted = true + s.res.Positions = append(s.res.Positions, slot.rows...) +} + +// sortPositions puts the array back in tick order and reports whether it had +// to. A throw burst emits the ticks behind it after later samples have already +// been appended; consumers walk this array as a timeline. Stable, so the +// per-tick block of players keeps the order it was captured in. +func (s *state) sortPositions() bool { + if s.positionBursts == 0 { + return false + } + sort.SliceStable(s.res.Positions, func(i, j int) bool { + return s.res.Positions[i].Tick < s.res.Positions[j].Tick + }) + return true +} + +// burstPositions emits the captured window behind a grenade throw and opens the +// window ahead of it. Overlapping throws extend the same run rather than +// duplicating rows. +// +// The rows flushed here are older than what is already in Result.Positions, so +// finalize sorts the array back into tick order. +func (s *state) burstPositions(tick int) { + if !s.liveRound { + return + } + for t := tick - throwBurstTicks; t <= tick; t++ { + if t < 0 { + continue + } + slot := &s.posRing[t%len(s.posRing)] + if slot.tick != t { + continue // that tick has already rolled out of the ring + } + s.emitPositions(slot) + } + if until := tick + throwBurstTicks; until > s.burstUntilTick { + s.burstUntilTick = until + } + s.positionBursts++ } // onWeaponFire records one row per shot. Firearms only — knife and diff --git a/internal/parser/types.go b/internal/parser/types.go index 1ed874f..ea78f7d 100644 --- a/internal/parser/types.go +++ b/internal/parser/types.go @@ -203,6 +203,11 @@ type EventInferno struct { // EventPosition is a low-frequency (~4Hz) sample of a single player's // world position + view yaw. The replay viewer interpolates between // adjacent samples to render a 2D radar timeline. +// +// The exception is the window around a grenade throw, where every tick is +// emitted (see throwBurstTicks). Rows stay sorted by tick, so a consumer +// walking the array in order still sees a timeline; the sample interval is +// just not constant. type EventPosition struct { Tick int `json:"tick"` Round int `json:"round,omitempty"` @@ -240,6 +245,14 @@ type EventPosition struct { // knife, or a grenade mid-throw — rather than the static loadout. // Empty when unarmed (dead / nothing equipped). ActiveWeapon string `json:"active_weapon,omitempty"` + // Ducked is the engine's FL_DUCKING flag: the player is fully crouched, + // eyes at ~46 units rather than ~64. A crouch still animating reads as + // standing, matching EventShotFired.IsCrouched. + // + // Without it a lineup mined out of a demo cannot state its own technique — + // stand vs crouch moves the release point by 18 units and lands the smoke + // somewhere else entirely. + Ducked bool `json:"ducked,omitempty"` } type EventFlash struct { @@ -345,8 +358,8 @@ type PlayerTrade struct { } type PlayerInfo struct { - SteamID string `json:"steam_id"` - Name string `json:"name"` + SteamID string `json:"steam_id"` + Name string `json:"name"` StartingSide string `json:"starting_side,omitempty"` Rank int `json:"rank,omitempty"` RankType int `json:"rank_type,omitempty"` @@ -354,11 +367,26 @@ type PlayerInfo struct { WinCount int `json:"win_count,omitempty"` } +// SchemaVersion identifies the shape of the Result blob. The API and the web +// replay both read it, so bump it whenever a field they consume is added, +// removed, or changes meaning, and say what changed here. +// +// 1 — everything up to and including smoke volumes and infernos. Blobs from +// before this constant existed carry no schema_version at all and are +// version 1 by definition. +// 2 — EventPosition.ducked, and a full-rate burst of positions in a ±10 tick +// window around every grenade throw (the sample interval is no longer a +// constant ~4Hz; rows remain sorted by tick). +const SchemaVersion = 2 + type Result struct { - TotalTicks int `json:"total_ticks"` - TickRate float64 `json:"tick_rate"` - MapName string `json:"map_name"` - WorkshopID string `json:"workshop_id,omitempty"` + // SchemaVersion is always emitted, including at its zero value, so a + // consumer can tell "old blob" from "field I forgot to read". + SchemaVersion int `json:"schema_version"` + TotalTicks int `json:"total_ticks"` + TickRate float64 `json:"tick_rate"` + MapName string `json:"map_name"` + WorkshopID string `json:"workshop_id,omitempty"` // GeometryValidated is true when a collision mesh was available for this // map, so the LOS-gated spotted/engagement stats are validated rather // than estimated. Emitted even when false (no omitempty) so consumers can diff --git a/internal/simulate/drift.go b/internal/simulate/drift.go new file mode 100644 index 0000000..359760a --- /dev/null +++ b/internal/simulate/drift.go @@ -0,0 +1,424 @@ +package simulate + +import ( + "errors" + "fmt" + "math" + "sync" + "sync/atomic" + + "github.com/5stackgg/demo-parser/internal/geometry" +) + +// Map-patch drift detection: which stored lineups did this map update break. +// +// Every lineup is flown twice with the same seed and the same constants, once +// against the mesh from before the patch and once against the mesh from after +// it. Nothing is claimed about either landing on its own — see the package doc +// — only about the vector between them, which is where the map moved under the +// lineup. + +// Bounds on one request. A flight is a thousand-odd raycasts against a mesh of +// a few hundred thousand triangles, and this process also parses demos. +const ( + // MaxLineups is the most one request may carry, streaming or not. A lineup + // costs two flights, measured at ~2 ms on one core and ~0.4 ms across eight + // (BenchmarkDriftBatchOnARealMesh, de_mirage), so the cap is twenty seconds + // of a single core and about four across a pool — comfortably inside the + // server's write timeout, with the response streamed. + MaxLineups = 10000 + // MaxBufferedLineups is the most that will be answered in a single JSON + // body. Past it the whole response is held in memory before a byte is + // written, so a bigger batch has to stream (or be chunked by the caller). + MaxBufferedLineups = 2000 + // chunkSize is how many lineups are simulated between emissions. Big + // enough that the worker pool stays busy, small enough that a streaming + // client sees progress and the in-flight slice stays trivial. + chunkSize = 128 +) + +// Thresholds are the cuts between verdicts, in source units. +// +// THESE ARE JUDGEMENTS, NOT MEASUREMENTS. There is no experiment that says a +// lineup moving 7 units is fine and one moving 9 is not; these are the numbers +// that make the report useful to a human reviewing a map patch. +type Thresholds struct { + // Unchanged is how far a landing may move and still count as noise. + // + // The two meshes are re-exports of a map that mostly did not change, but + // they are separate float32 files: a vertex can round differently, a + // triangle can be split differently, and a bounce grazing that seam comes + // off a fraction of a degree apart and lands a few units away. Eight units + // is a quarter of a player's width and half a step height — below anything + // a thrower could act on, and above the seam noise. + Unchanged float64 `json:"unchanged"` + // Major is where a move stops being a nudge. A smoke's radius is 144, a + // player is 32 wide: past 64 units a cloud no longer covers the same gap + // and a pop-flash no longer blinds the same doorway. Below it the lineup + // probably still does its job and wants an eyeball; above it, it does not. + Major float64 `json:"major"` +} + +// DefaultThresholds — see the Thresholds doc for why these numbers. +func DefaultThresholds() Thresholds { + return Thresholds{Unchanged: 8.0, Major: 64.0} +} + +// Validate rejects a threshold pair that cannot be applied. +func (t Thresholds) Validate() error { + if t.Unchanged < 0 || t.Major < 0 { + return errors.New("thresholds must not be negative") + } + if t.Major < t.Unchanged { + return errors.New("thresholds.major must be at least thresholds.unchanged") + } + return nil +} + +// LineupSeed is one stored lineup as the library holds it. The seed is +// optional because it is: lineups mined out of demos before 5stack started +// recording throws have a landing spot and no way to reproduce the throw. +// Those are reported unsimulatable rather than guessed at. +type LineupSeed struct { + ID string `json:"id"` + NadeType string `json:"nade_type"` + InitialPosition *Point `json:"initial_position,omitempty"` + InitialVelocity *Point `json:"initial_velocity,omitempty"` +} + +// Verdict is what the differential says about one lineup. +type Verdict string + +const ( + // VerdictUnchanged — both meshes put the grenade in the same place, within + // Thresholds.Unchanged. The map did not change under this lineup. + VerdictUnchanged Verdict = "unchanged" + // VerdictMoved — both meshes resolve, and the two endpoints are apart. + // Severity says how far. + VerdictMoved Verdict = "moved" + // VerdictBroken — the lineup resolved on the old mesh and does not on the + // new one: it is now inside geometry, off the map, or never settles. + VerdictBroken Verdict = "broken" + // VerdictUnsimulatable — nothing can be said. No recorded seed, an unknown + // grenade type, or a flight that fails to resolve on BOTH meshes, which + // says something is wrong with the seed or the model rather than the map. + VerdictUnsimulatable Verdict = "unsimulatable" +) + +// LineupDrift is one lineup's answer. +type LineupDrift struct { + // Index is the lineup's position in the request, so results can be matched + // up even when ids are missing or duplicated. + Index int `json:"index"` + ID string `json:"id,omitempty"` + Verdict Verdict `json:"verdict"` + // Reason is why, for anything other than unchanged. Stable enough to + // switch on, but the verdict is the field to branch on. + Reason string `json:"reason,omitempty"` + // Severity is "minor" or "major" on a moved lineup, and empty otherwise. + Severity string `json:"severity,omitempty"` + // From and To are the two flights. Present whenever the flight ran at all, + // including when it did not resolve — the stop reason is the useful part + // of a broken lineup. Absent on unsimulatable seeds, which never flew. + From *ComparableOutcome `json:"from,omitempty"` + To *ComparableOutcome `json:"to,omitempty"` + // Distance, DistanceXY and DistanceZ are how far the endpoint moved, + // source units. Null unless BOTH flights resolved: the gap between a real + // landing and a flight that fell out of the map is not a distance that + // means anything. + Distance *float64 `json:"distance,omitempty"` + DistanceXY *float64 `json:"distance_xy,omitempty"` + DistanceZ *float64 `json:"distance_z,omitempty"` +} + +// DriftRequest asks which of a batch of lineups a map patch moved. +type DriftRequest struct { + Map string `json:"map"` + // From and To name the mesh revisions to compare — a jsDelivr tag + // ("17595823-4"), an owner/repo@tag, or an http(s) base. Empty means the + // revision this process is pinned to, which is the useful spelling for To + // right after a deploy. + From string `json:"from"` + To string `json:"to"` + // Lineups is the batch. Order is preserved in the response. + Lineups []LineupSeed `json:"lineups"` + // Constants overrides individual physics knobs. Any override applies to + // BOTH sides — that is the invariant the whole method rests on, so it is + // not expressible per side. + Constants *ConstantOverrides `json:"constants,omitempty"` + // UnchangedRadius and MajorRadius override the verdict cuts. They are + // request fields because they are judgements, not measurements. + UnchangedRadius *float64 `json:"unchanged_radius,omitempty"` + MajorRadius *float64 `json:"major_radius,omitempty"` + // Stream asks for NDJSON instead of one JSON body. Required above + // MaxBufferedLineups. + Stream bool `json:"stream,omitempty"` +} + +// Thresholds is the verdict cuts this request will be judged against — +// exported so a streaming caller can be told them before the first result is +// computed, without recomputing the defaulting rules somewhere else. +func (r DriftRequest) Thresholds() Thresholds { + t := DefaultThresholds() + if r.UnchangedRadius != nil { + t.Unchanged = *r.UnchangedRadius + } + if r.MajorRadius != nil { + t.Major = *r.MajorRadius + } + return t +} + +// DriftSummary is the batch-level count, which is what a reviewer reads first. +type DriftSummary struct { + Lineups int `json:"lineups"` + Unchanged int `json:"unchanged"` + Moved int `json:"moved"` + Broken int `json:"broken"` + Unsimulatable int `json:"unsimulatable"` + // MaxDistance is the largest move among lineups that resolved on both + // sides. Zero when none did. + MaxDistance float64 `json:"max_distance"` +} + +func (s *DriftSummary) add(d LineupDrift) { + s.Lineups++ + switch d.Verdict { + case VerdictUnchanged: + s.Unchanged++ + case VerdictMoved: + s.Moved++ + case VerdictBroken: + s.Broken++ + default: + s.Unsimulatable++ + } + if d.Distance != nil && *d.Distance > s.MaxDistance { + s.MaxDistance = *d.Distance + } +} + +// DriftResponse is the whole answer. Results is empty when the caller streamed. +type DriftResponse struct { + Map string `json:"map"` + From string `json:"from"` + To string `json:"to"` + Constants Constants `json:"constants"` + Thresholds Thresholds `json:"thresholds"` + Summary DriftSummary `json:"summary"` + Results []LineupDrift `json:"results,omitempty"` + // Caveats travels with the payload on purpose. Everything downstream of + // here is a screen someone reads, and the one thing they must not conclude + // from it is that these coordinates are where a grenade lands. + Caveats []string `json:"caveats"` +} + +// DriftCaveats is what a consumer of this endpoint has to be told, every time. +func DriftCaveats() []string { + return []string{ + "comparison points are simulator output, not real landings: the physics model is " + + "approximate and unfitted, and no coordinate here may be shown to a player as " + + "where their nade lands", + "only the difference between the two runs is meaningful; a constant model error " + + "appears on both sides and cancels", + "a lineup with no recorded initial_velocity is unsimulatable, not unchanged", + "an 'unchanged' verdict means the collision mesh did not move under this lineup — " + + "it says nothing about textures, clipping, or anything the .tri does not carry", + } +} + +// DriftOptions are the server-side knobs, kept out of the request body because +// they are this process's business and not the caller's. +type DriftOptions struct { + // Workers bounds how many flights run at once. Zero or less means one. + // Flights are independent and each is deterministic, so the pool changes + // throughput and nothing else. + Workers int + // Emit, when set, receives results in request order as chunks complete, + // and Results is left empty on the response. This is what makes a batch of + // thousands answerable without buffering the whole thing. + // + // The slice is reused between chunks: write it out or copy it, never keep + // it. Returning an error stops the run and is returned from Drift. + Emit func([]LineupDrift) error +} + +// Drift flies every lineup against both meshes and reports what moved. +// +// from and to must be the SAME map at two revisions. Nothing here can check +// that — two unrelated meshes will produce a report saying every lineup broke, +// which is technically true and useless. +func Drift(from, to *geometry.Mesh, req DriftRequest, opts DriftOptions) (DriftResponse, error) { + if from == nil || from.Triangles() == 0 || to == nil || to.Triangles() == 0 { + return DriftResponse{}, ErrNoMesh + } + if len(req.Lineups) == 0 { + return DriftResponse{}, errors.New("lineups must not be empty") + } + if len(req.Lineups) > MaxLineups { + return DriftResponse{}, fmt.Errorf("too many lineups: %d (max %d)", len(req.Lineups), MaxLineups) + } + consts := req.Constants.Apply(DefaultConstants()) + if err := consts.Validate(); err != nil { + return DriftResponse{}, fmt.Errorf("constants: %w", err) + } + thresholds := req.Thresholds() + if err := thresholds.Validate(); err != nil { + return DriftResponse{}, err + } + + out := DriftResponse{ + Map: req.Map, + From: req.From, + To: req.To, + Constants: consts, + Thresholds: thresholds, + Caveats: DriftCaveats(), + } + if opts.Emit == nil { + out.Results = make([]LineupDrift, 0, len(req.Lineups)) + } + + workers := opts.Workers + if workers < 1 { + workers = 1 + } + if workers > chunkSize { + workers = chunkSize + } + + // Chunked rather than one big pool: results stay in request order without + // any reordering step, the streaming and buffered paths are the same code, + // and at most chunkSize results exist at once. + buf := make([]LineupDrift, 0, chunkSize) + for start := 0; start < len(req.Lineups); start += chunkSize { + end := min(start+chunkSize, len(req.Lineups)) + batch := req.Lineups[start:end] + buf = buf[:len(batch)] + + var ( + wg sync.WaitGroup + next atomic.Int64 + ) + for w := 0; w < workers; w++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + i := int(next.Add(1)) - 1 + if i >= len(batch) { + return + } + // Each worker owns the index it claimed, so the results + // land in request order with no reordering pass. + buf[i] = driftOne(from, to, start+i, batch[i], consts, thresholds) + } + }() + } + wg.Wait() + + for i := range buf { + out.Summary.add(buf[i]) + } + if opts.Emit != nil { + if err := opts.Emit(buf); err != nil { + return out, err + } + continue + } + out.Results = append(out.Results, buf...) + } + return out, nil +} + +// driftOne is the whole verdict, for one lineup. +func driftOne(fromMesh, toMesh *geometry.Mesh, index int, seed LineupSeed, c Constants, t Thresholds) LineupDrift { + d := LineupDrift{Index: index, ID: seed.ID} + + nade, ok := ParseNadeType(seed.NadeType) + if !ok { + return unsimulatable(d, fmt.Sprintf("unknown nade_type %q", seed.NadeType)) + } + if seed.InitialPosition == nil || seed.InitialVelocity == nil { + // The common case for a demo-mined lineup: we know where it landed and + // nothing about how it was thrown. Saying "unchanged" here would be a + // lie of omission, and guessing a throw would be a plain lie. + return unsimulatable(d, "no recorded initial_position/initial_velocity: lineup cannot be re-simulated") + } + flight := Seed{ + Type: nade, + Position: seed.InitialPosition.vec(), + Velocity: seed.InitialVelocity.vec(), + } + + before, err := SimulateForComparison(fromMesh, flight, c) + if err != nil { + return unsimulatable(d, err.Error()) + } + after, err := SimulateForComparison(toMesh, flight, c) + if err != nil { + return unsimulatable(d, err.Error()) + } + d.From, d.To = &before, &after + + switch { + case !before.Resolved && !after.Resolved: + // The map is not the problem: the same thing happens on both meshes. + d.Verdict = VerdictUnsimulatable + d.Reason = fmt.Sprintf("flight does not resolve on either mesh (%s): bad seed or a limit of the model", after.Stop) + return d + case before.Resolved && !after.Resolved: + d.Verdict = VerdictBroken + d.Reason = brokenReason(after.Stop) + return d + case !before.Resolved && after.Resolved: + // Backwards drift: the lineup did not work on the old mesh and does on + // the new one. Rare, and still a change the reviewer wants to see, so + // it is reported as moved with the reason spelled out rather than + // hidden under "unchanged". + d.Verdict = VerdictMoved + d.Severity = "major" + d.Reason = fmt.Sprintf("did not resolve on the old mesh (%s) but does on the new one", before.Stop) + return d + } + + dx := after.ComparisonPoint.X - before.ComparisonPoint.X + dy := after.ComparisonPoint.Y - before.ComparisonPoint.Y + dz := after.ComparisonPoint.Z - before.ComparisonPoint.Z + dist := math.Sqrt(dx*dx + dy*dy + dz*dz) + xy := math.Hypot(dx, dy) + z := math.Abs(dz) + d.Distance, d.DistanceXY, d.DistanceZ = &dist, &xy, &z + + if dist < t.Unchanged { + d.Verdict = VerdictUnchanged + return d + } + d.Verdict = VerdictMoved + d.Severity = "minor" + if dist >= t.Major { + d.Severity = "major" + } + d.Reason = fmt.Sprintf("landing moved %.1f units", dist) + return d +} + +func unsimulatable(d LineupDrift, reason string) LineupDrift { + d.Verdict = VerdictUnsimulatable + d.Reason = reason + return d +} + +func brokenReason(stop StopReason) string { + switch stop { + case StopInsideGeometry: + return "landing is now inside geometry" + case StopStartSealed: + return "the throw position is now inside geometry" + case StopOutOfWorld: + return "the grenade now leaves the map" + case StopMaxFlight: + return "the grenade no longer comes to rest" + } + return fmt.Sprintf("no longer resolves (%s)", stop) +} diff --git a/internal/simulate/drift_test.go b/internal/simulate/drift_test.go new file mode 100644 index 0000000..5af9805 --- /dev/null +++ b/internal/simulate/drift_test.go @@ -0,0 +1,452 @@ +package simulate + +import ( + "math" + "math/rand" + "testing" +) + +func seedAt(id string, px, py, pz, vx, vy, vz float64, nade string) LineupSeed { + return LineupSeed{ + ID: id, + NadeType: nade, + InitialPosition: &Point{X: px, Y: py, Z: pz}, + InitialVelocity: &Point{X: vx, Y: vy, Z: vz}, + } +} + +func runDrift(t *testing.T, from, to *meshPair, lineups []LineupSeed, mut func(*DriftRequest)) DriftResponse { + t.Helper() + req := DriftRequest{Map: "de_test", From: from.ref, To: to.ref, Lineups: lineups} + if mut != nil { + mut(&req) + } + res, err := Drift(from.mesh, to.mesh, req, DriftOptions{Workers: 4}) + if err != nil { + t.Fatalf("drift: %v", err) + } + if len(res.Results) != len(lineups) { + t.Fatalf("got %d results for %d lineups", len(res.Results), len(lineups)) + } + return res +} + +// THE PROPERTY THE WHOLE DESIGN RESTS ON. +// +// If the same geometry is on both sides of the comparison, nothing may be +// reported as having moved or broken — whatever the physics constants are, and +// whatever the throw is. The model's accuracy is irrelevant to this: an error +// in the constants lands identically on both sides and subtracts out. If this +// ever fails, every drift report this service has ever produced is noise. +func TestSameGeometryOnBothSidesNeverReportsDrift(t *testing.T) { + tris := terrain() + left := newMeshPair(t, tris) + right := newMeshPair(t, tris) + + rng := rand.New(rand.NewSource(20260818)) + for trial := 0; trial < 12; trial++ { + c := randomConstants(rng) + if err := c.Validate(); err != nil { + t.Fatalf("trial %d generated invalid constants %+v: %v", trial, c, err) + } + lineups := randomLineups(rng, 24) + res := runDrift(t, left, right, lineups, func(req *DriftRequest) { + req.Constants = overridesFrom(c) + }) + for _, d := range res.Results { + switch d.Verdict { + case VerdictMoved, VerdictBroken: + t.Fatalf("trial %d lineup %d: identical meshes reported %q (%s)\n from %+v\n to %+v", + trial, d.Index, d.Verdict, d.Reason, d.From, d.To) + } + if d.Distance != nil && *d.Distance != 0 { + t.Fatalf("trial %d lineup %d: identical meshes moved the landing by %v", + trial, d.Index, *d.Distance) + } + } + if res.Summary.Moved != 0 || res.Summary.Broken != 0 || res.Summary.MaxDistance != 0 { + t.Fatalf("trial %d summary reports drift against identical meshes: %+v", trial, res.Summary) + } + } +} + +// The same, against a real shipped mesh. A synthetic box has a handful of +// triangles and no seams worth grazing; de_mirage has hundreds of thousands, +// and a bounce off one crosses exactly the kind of boundary where a sloppy +// raycaster would answer differently between two builds. +func TestSameRealMeshNeverReportsDrift(t *testing.T) { + base := realMeshRevision(t) + other := realMeshRevision(t) + from := loadMesh(t, base, "de_mirage") + to := loadMesh(t, other, "de_mirage") + if from == to { + t.Fatal("test setup: wanted two separately built meshes") + } + + // Around Mirage's A site, thrown in every direction so the flights land all + // over the map rather than all in one open area. + rng := rand.New(rand.NewSource(7)) + lineups := make([]LineupSeed, 0, 64) + for i := 0; i < 64; i++ { + a := rng.Float64() * 2 * math.Pi + lineups = append(lineups, seedAt( + "mirage", -2300, 0, -64, + 700*math.Cos(a), 700*math.Sin(a), rng.Float64()*400-100, + []string{"Smoke", "HE", "Flash", "Molotov"}[i%4], + )) + } + res, err := Drift(from, to, DriftRequest{ + Map: "de_mirage", From: base, To: other, Lineups: lineups, + }, DriftOptions{Workers: 4}) + if err != nil { + t.Fatalf("drift: %v", err) + } + if res.Summary.Moved != 0 || res.Summary.Broken != 0 { + for _, d := range res.Results { + if d.Verdict == VerdictMoved || d.Verdict == VerdictBroken { + t.Errorf("lineup %d on identical real meshes: %s (%s) from %+v to %+v", + d.Index, d.Verdict, d.Reason, d.From, d.To) + } + } + t.Fatalf("identical de_mirage meshes reported drift: %+v", res.Summary) + } + if res.Summary.Unchanged == 0 { + t.Fatal("no lineup resolved on de_mirage; the test is not exercising anything") + } + t.Logf("de_mirage self-comparison: %+v", res.Summary) +} + +// A map update that raises the ground under a lineup moves where it lands. +func TestNewGeometryMovesTheLanding(t *testing.T) { + from := newMeshPair(t, terrain()) + to := newMeshPair(t, terrainWithPlatform()) + lineups := []LineupSeed{seedAt("onto-the-platform", 0, 0, 64, 500, 0, 200, "Smoke")} + + res := runDrift(t, from, to, lineups, nil) + d := res.Results[0] + if d.Verdict != VerdictMoved { + t.Fatalf("a platform under the landing should move it, got %q (%s)\n from %+v\n to %+v", + d.Verdict, d.Reason, d.From, d.To) + } + if d.Distance == nil || *d.Distance < res.Thresholds.Unchanged { + t.Fatalf("distance %v does not clear the unchanged threshold", d.Distance) + } + if d.From.ComparisonPoint.Z > 8 { + t.Fatalf("the old landing should be on the ground, got z=%v", d.From.ComparisonPoint.Z) + } + if d.To.ComparisonPoint.Z < 56 { + t.Fatalf("the new landing should be on top of the 64-unit platform, got z=%v", d.To.ComparisonPoint.Z) + } + if d.Severity == "" { + t.Fatal("a moved lineup should carry a severity") + } + if res.Summary.Moved != 1 || res.Summary.Unchanged != 0 { + t.Fatalf("summary %+v", res.Summary) + } +} + +// The floor under a lineup is removed: the grenade now falls out of the map and +// the lineup no longer resolves at all. +func TestRemovedFloorBreaksTheLineup(t *testing.T) { + // A floor with a rectangular hole, and the same floor with the hole + // patched. The patch is the only difference between the two meshes. + holed := floorWithHole() + whole := append(append([]tri(nil), holed...), floorQuad(1200, 0, 0, 400)...) + + from := newMeshPair(t, whole) + to := newMeshPair(t, holed) + lineups := []LineupSeed{seedAt("into-the-hole", 0, 0, 64, 500, 0, 200, "Smoke")} + + res := runDrift(t, from, to, lineups, nil) + d := res.Results[0] + if d.Verdict != VerdictBroken { + t.Fatalf("a lineup landing in a new hole should be broken, got %q (%s)\n from %+v\n to %+v", + d.Verdict, d.Reason, d.From, d.To) + } + if d.To.Stop != StopOutOfWorld { + t.Fatalf("expected the flight to leave the map, got %q", d.To.Stop) + } + if d.Distance != nil { + t.Fatalf("a broken lineup has no meaningful distance, got %v", *d.Distance) + } + if res.Summary.Broken != 1 { + t.Fatalf("summary %+v", res.Summary) + } +} + +// A map update that builds something where the thrower stood. +func TestSealedThrowPositionBreaksTheLineup(t *testing.T) { + before := terrain() + after := append(append([]tri(nil), before...), + box(pt(-6, -6, 58), pt(6, 6, 70))...) + + from := newMeshPair(t, before) + to := newMeshPair(t, after) + res := runDrift(t, from, to, []LineupSeed{seedAt("walled-in", 0, 0, 64, 500, 0, 200, "Smoke")}, nil) + + d := res.Results[0] + if d.Verdict != VerdictBroken || d.To.Stop != StopStartSealed { + t.Fatalf("a throw spot filled in by the update should be broken, got %q (%s) stop %q", + d.Verdict, d.Reason, d.To.Stop) + } +} + +// A landing that ends up buried in the new geometry. The enclosure probe is +// raised for this one: at its default a pocket tight enough to trap a grenade +// is also too tight for one to fly into, so the mechanism is exercised at a +// scale a test can actually build. +func TestLandingInsideNewGeometryBreaksTheLineup(t *testing.T) { + from := newMeshPair(t, chamber(false)) + to := newMeshPair(t, chamber(true)) + probe := 320.0 + res := runDrift(t, from, to, []LineupSeed{seedAt("roofed-in", -100, 0, 125, 500, 0, 0, "Smoke")}, + func(req *DriftRequest) { + req.Constants = &ConstantOverrides{EnclosureProbe: &probe} + }) + + d := res.Results[0] + if d.Verdict != VerdictBroken || d.To.Stop != StopInsideGeometry { + t.Fatalf("a landing with no space left around it should be broken as inside geometry, got %q (%s) stop %q", + d.Verdict, d.Reason, d.To.Stop) + } + if !d.From.Resolved { + t.Fatalf("the lineup should have worked before the roof went on: %+v", d.From) + } +} + +// Most lineups in the library were mined out of demos and have no recorded +// throw. Reporting those as unchanged would be a quiet lie — they were never +// checked — and inventing a throw for them would be a loud one. +func TestLineupWithNoSeedIsUnsimulatable(t *testing.T) { + from := newMeshPair(t, terrain()) + to := newMeshPair(t, terrain()) + lineups := []LineupSeed{ + {ID: "no-seed-at-all", NadeType: "Smoke"}, + {ID: "position-only", NadeType: "Smoke", InitialPosition: &Point{Z: 64}}, + {ID: "velocity-only", NadeType: "Smoke", InitialVelocity: &Point{X: 500}}, + seedAt("fine", 0, 0, 64, 500, 0, 200, "Smoke"), + } + res := runDrift(t, from, to, lineups, nil) + + for _, d := range res.Results[:3] { + if d.Verdict != VerdictUnsimulatable { + t.Fatalf("lineup %q with no seed should be unsimulatable, got %q", d.ID, d.Verdict) + } + if d.From != nil || d.To != nil { + t.Fatalf("lineup %q never flew, so it should carry no outcomes", d.ID) + } + if d.Reason == "" { + t.Fatalf("lineup %q should say why it could not be simulated", d.ID) + } + } + if res.Results[3].Verdict != VerdictUnchanged { + t.Fatalf("the seeded lineup should still have been answered, got %q", res.Results[3].Verdict) + } + if res.Summary.Unsimulatable != 3 || res.Summary.Unchanged != 1 { + t.Fatalf("summary %+v", res.Summary) + } +} + +func TestUnknownNadeTypeIsUnsimulatable(t *testing.T) { + from := newMeshPair(t, terrain()) + to := newMeshPair(t, terrain()) + res := runDrift(t, from, to, []LineupSeed{seedAt("what", 0, 0, 64, 500, 0, 200, "banana")}, nil) + if res.Results[0].Verdict != VerdictUnsimulatable { + t.Fatalf("an unknown grenade should be unsimulatable, got %q", res.Results[0].Verdict) + } +} + +// A flight that fails on BOTH meshes says nothing about the map, so it is not +// "broken" — it is a seed or a model this service cannot answer for. +func TestFailingOnBothMeshesIsUnsimulatableNotBroken(t *testing.T) { + from := newMeshPair(t, floorQuad(0, 0, 0, 200)) + to := newMeshPair(t, floorQuad(0, 0, 0, 200)) + res := runDrift(t, from, to, []LineupSeed{seedAt("off-the-edge", 0, 0, 64, 900, 0, 100, "Smoke")}, nil) + d := res.Results[0] + if d.Verdict != VerdictUnsimulatable { + t.Fatalf("a flight that fails on both sides should be unsimulatable, got %q (%s)", d.Verdict, d.Reason) + } + if d.From == nil || d.To == nil { + t.Fatal("both flights ran, so both outcomes should be reported") + } +} + +// Streaming must produce exactly the buffered answer, in the same order — the +// two paths differ only in where the results are written. +func TestStreamingMatchesTheBufferedAnswer(t *testing.T) { + from := newMeshPair(t, terrain()) + to := newMeshPair(t, terrainWithPlatform()) + + rng := rand.New(rand.NewSource(99)) + lineups := randomLineups(rng, chunkSize*2+7) + req := DriftRequest{Map: "de_test", From: from.ref, To: to.ref, Lineups: lineups} + + buffered, err := Drift(from.mesh, to.mesh, req, DriftOptions{Workers: 4}) + if err != nil { + t.Fatalf("buffered: %v", err) + } + + var streamed []LineupDrift + stream, err := Drift(from.mesh, to.mesh, req, DriftOptions{ + Workers: 4, + Emit: func(batch []LineupDrift) error { + streamed = append(streamed, batch...) + return nil + }, + }) + if err != nil { + t.Fatalf("streamed: %v", err) + } + if len(stream.Results) != 0 { + t.Fatal("a streamed run should not also buffer its results") + } + if stream.Summary != buffered.Summary { + t.Fatalf("summaries differ:\n buffered %+v\n streamed %+v", buffered.Summary, stream.Summary) + } + if len(streamed) != len(buffered.Results) { + t.Fatalf("streamed %d results, buffered %d", len(streamed), len(buffered.Results)) + } + for i := range streamed { + if streamed[i].Index != i { + t.Fatalf("result %d is out of order (index %d)", i, streamed[i].Index) + } + if !sameDrift(streamed[i], buffered.Results[i]) { + t.Fatalf("result %d differs:\n streamed %+v\n buffered %+v", i, streamed[i], buffered.Results[i]) + } + } + if buffered.Summary.Moved == 0 { + t.Fatal("the two meshes differ, so something should have moved") + } +} + +// The worker pool changes throughput and nothing else. +func TestWorkerCountDoesNotChangeTheAnswer(t *testing.T) { + from := newMeshPair(t, terrain()) + to := newMeshPair(t, terrainWithPlatform()) + rng := rand.New(rand.NewSource(4)) + req := DriftRequest{Map: "de_test", From: from.ref, To: to.ref, Lineups: randomLineups(rng, 40)} + + one, err := Drift(from.mesh, to.mesh, req, DriftOptions{Workers: 1}) + if err != nil { + t.Fatalf("workers=1: %v", err) + } + many, err := Drift(from.mesh, to.mesh, req, DriftOptions{Workers: 16}) + if err != nil { + t.Fatalf("workers=16: %v", err) + } + if one.Summary != many.Summary { + t.Fatalf("summaries differ by worker count:\n 1 %+v\n 16 %+v", one.Summary, many.Summary) + } + for i := range one.Results { + if !sameDrift(one.Results[i], many.Results[i]) { + t.Fatalf("result %d differs by worker count:\n 1 %+v\n 16 %+v", i, one.Results[i], many.Results[i]) + } + } +} + +func TestDriftRejectsWhatItCannotAnswer(t *testing.T) { + pair := newMeshPair(t, terrain()) + good := []LineupSeed{seedAt("a", 0, 0, 64, 500, 0, 200, "Smoke")} + + if _, err := Drift(nil, pair.mesh, DriftRequest{Lineups: good}, DriftOptions{}); err == nil { + t.Error("a missing from-mesh should be an error") + } + if _, err := Drift(pair.mesh, nil, DriftRequest{Lineups: good}, DriftOptions{}); err == nil { + t.Error("a missing to-mesh should be an error") + } + if _, err := Drift(pair.mesh, pair.mesh, DriftRequest{}, DriftOptions{}); err == nil { + t.Error("an empty batch should be an error") + } + bad := -1.0 + if _, err := Drift(pair.mesh, pair.mesh, DriftRequest{ + Lineups: good, UnchangedRadius: &bad, + }, DriftOptions{}); err == nil { + t.Error("a negative threshold should be an error") + } + zero := 0.0 + if _, err := Drift(pair.mesh, pair.mesh, DriftRequest{ + Lineups: good, Constants: &ConstantOverrides{TimeStep: &zero}, + }, DriftOptions{}); err == nil { + t.Error("a zero timestep should be an error") + } +} + +func TestSeverityTracksTheThresholds(t *testing.T) { + from := newMeshPair(t, terrain()) + to := newMeshPair(t, terrainWithPlatform()) + lineups := []LineupSeed{seedAt("crate", 0, 0, 64, 500, 0, 200, "Smoke")} + + tiny := 0.001 + res := runDrift(t, from, to, lineups, func(req *DriftRequest) { req.MajorRadius = &tiny; req.UnchangedRadius = &tiny }) + if res.Results[0].Severity != "major" { + t.Fatalf("with a hair-thin major threshold everything is major, got %q", res.Results[0].Severity) + } + huge := 100000.0 + res = runDrift(t, from, to, lineups, func(req *DriftRequest) { req.MajorRadius = &huge }) + if res.Results[0].Severity != "minor" { + t.Fatalf("with an unreachable major threshold nothing is major, got %q", res.Results[0].Severity) + } + res = runDrift(t, from, to, lineups, func(req *DriftRequest) { req.UnchangedRadius = &huge; req.MajorRadius = &huge }) + if res.Results[0].Verdict != VerdictUnchanged { + t.Fatalf("a threshold wider than the move should call it unchanged, got %q", res.Results[0].Verdict) + } +} + +// The caveats ride along with the payload, because the one conclusion a reader +// must not draw from a screen full of coordinates is that they are real. +func TestResponseCarriesItsCaveats(t *testing.T) { + pair := newMeshPair(t, terrain()) + res := runDrift(t, pair, pair, []LineupSeed{seedAt("a", 0, 0, 64, 500, 0, 200, "Smoke")}, nil) + if len(res.Caveats) == 0 { + t.Fatal("a drift response must carry its caveats") + } + if res.Constants != DefaultConstants() { + t.Fatal("the response must echo the constants the answer was computed with") + } +} + +// One lineup is two flights, so this is the cost of a single row of a drift +// report. It is the number the batch limits and the README are sized from; +// re-run it when the integrator or the BVH changes. +func BenchmarkDriftFlightPairOnARealMesh(b *testing.B) { + mesh := loadMesh(b, realMeshRevision(b), "de_mirage") + seed := Seed{Type: Smoke, Position: pt(-2300, 0, -64), Velocity: pt(600, 300, 250)} + c := DefaultConstants() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := SimulateForComparison(mesh, seed, c); err != nil { + b.Fatal(err) + } + if _, err := SimulateForComparison(mesh, seed, c); err != nil { + b.Fatal(err) + } + } +} + +// A whole batch across the worker pool, which is what a caller actually waits +// on. Reported per lineup so it can be read straight off against a batch size. +func BenchmarkDriftBatchOnARealMesh(b *testing.B) { + b.Setenv("MAP_MESH_CACHE", "4") + from := loadMesh(b, realMeshRevision(b), "de_mirage") + to := loadMesh(b, realMeshRevision(b), "de_mirage") + b.Logf("de_mirage: %d triangles, %.1f MiB per mesh, %.1f MiB resident for the pair", + from.Triangles(), float64(from.Bytes())/(1<<20), float64(from.Bytes()+to.Bytes())/(1<<20)) + + rng := rand.New(rand.NewSource(3)) + const batch = 256 + lineups := make([]LineupSeed, 0, batch) + types := []string{"Smoke", "HE", "Flash", "Molotov", "Decoy"} + for i := 0; i < batch; i++ { + a := rng.Float64() * 2 * math.Pi + lineups = append(lineups, seedAt("bench", -2300, 0, -64, + 700*math.Cos(a), 700*math.Sin(a), rng.Float64()*400-100, types[i%len(types)])) + } + req := DriftRequest{Map: "de_mirage", Lineups: lineups} + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := Drift(from, to, req, DriftOptions{Workers: 8}); err != nil { + b.Fatal(err) + } + } + b.ReportMetric(float64(b.Elapsed().Nanoseconds())/float64(b.N*batch)/1e6, "ms/lineup") +} diff --git a/internal/simulate/flight.go b/internal/simulate/flight.go new file mode 100644 index 0000000..a9daa2e --- /dev/null +++ b/internal/simulate/flight.go @@ -0,0 +1,559 @@ +// Package simulate is a deterministic grenade flight model whose output is +// meaningful ONLY as a differential. +// +// # What this is for +// +// When Valve ships a map update we need to know which stored lineups it broke. +// The question is not "where does this nade land" — it is "does this nade land +// somewhere different than it used to". Those are very different problems. The +// second one is tractable without an accurate physics model, because the same +// deterministic simulator runs against the old collision mesh and the new one, +// and every constant error in the model appears identically on both sides and +// cancels. What survives the subtraction is the mesh change, which is the only +// thing we were asking about. +// +// # What this is NOT +// +// This is not CS2's grenade physics. The constants below were picked to be +// plausible and self-consistent, not measured against the game. An absolute +// landing point out of this package is wrong by an unknown amount, and showing +// one to a player as "where your nade lands" would be a confident lie. That is +// why the flight function is SimulateForComparison and the position it returns +// is ComparisonPoint: there is no way to spell a use of this package that reads +// as a prediction. If you want a real landing point, throw the nade on a real +// server and record it — the lineup library already stores those triples. +// +// # Determinism +// +// Everything here is fixed-timestep float64 arithmetic in a fixed order. There +// is no randomness, no map iteration, no time or goroutine dependence, and no +// dependence on how the mesh was loaded beyond its triangles. Identical inputs +// give byte-identical outputs, in this process and the next one. The whole +// method rests on that: if the simulator were noisy, every lineup would look +// like it had drifted. +package simulate + +import ( + "errors" + "fmt" + "math" + "strings" + + "github.com/5stackgg/demo-parser/internal/geometry" + "github.com/golang/geo/r3" +) + +// NadeType is which grenade is being thrown. The spellings match the parser's +// grenadeTypeCode, so a lineup mined from a demo can be fed straight in. +type NadeType string + +const ( + Smoke NadeType = "Smoke" + HE NadeType = "HE" + Flash NadeType = "Flash" + Molotov NadeType = "Molotov" + Decoy NadeType = "Decoy" +) + +// ParseNadeType accepts the parser's codes plus the spellings the game and the +// API use for the same thing. +func ParseNadeType(s string) (NadeType, bool) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "smoke", "smokegrenade", "smoke_grenade": + return Smoke, true + case "he", "hegrenade", "he_grenade", "frag": + return HE, true + case "flash", "flashbang": + return Flash, true + case "molotov", "incendiary", "incgrenade", "firebomb": + return Molotov, true + case "decoy": + return Decoy, true + } + return "", false +} + +// Constants are the model's physics knobs. +// +// THEY ARE APPROXIMATIONS, NOT MEASUREMENTS. Each is a plausible number chosen +// so the model behaves like a grenade and behaves the same way every time; none +// was fitted to recorded CS2 throws. That is sound for the differential — both +// sides of a comparison carry the same error — and unsound for anything else. +// +// They are a value rather than package constants precisely so they can be +// calibrated later: 5stack now records exact initial_position, initial_velocity +// and detonation triples from real servers, so a fit against real throws is a +// realistic follow-up. When that happens, only DefaultConstants changes. +type Constants struct { + // TimeStep is the fixed integration step, in seconds. 1/128 is a CS2 + // server tick at the higher of the two common rates; a grenade's whole + // flight is then a thousand-odd steps, which is cheap and fine-grained + // enough that a bounce lands within a couple of units of the surface. + TimeStep float64 `json:"time_step"` + // Gravity is the downward acceleration, in source units/s². Source + // projectiles run at a fraction of world gravity (sv_gravity 800 with a + // 0.4 projectile scale is where 320 comes from), which is also what makes + // a max-range throw carry the ~1700 units it does rather than ~700. + Gravity float64 `json:"gravity"` + // Radius is the grenade's collision radius, in source units. The flight is + // integrated as a point and backed off from each surface by this, which is + // exact for a head-on impact and slightly early for a glancing one. + Radius float64 `json:"radius"` + // Restitution is the fraction of the normal-direction speed kept through a + // real bounce, and Friction the fraction of the tangential speed lost in + // the same bounce. A grenade that hits a wall hard comes off it at a bit + // under half speed and noticeably deflected. + Restitution float64 `json:"restitution"` + Friction float64 `json:"friction"` + // ContactNormalSpeed separates a bounce from a resting contact. Below it + // the grenade is settling onto the surface rather than hitting it: the + // normal component is simply removed instead of being reflected, which is + // what keeps a grenade on a slope rolling down it instead of chattering + // against it and freezing in place. + ContactNormalSpeed float64 `json:"contact_normal_speed"` + // RollingFriction is the tangential decay while in resting contact, per + // second (not per bounce). Low, because grenades roll. + RollingFriction float64 `json:"rolling_friction"` + // FloorNormalZ is how "up" a surface must face to count as ground: a + // grenade rests on a floor and bounces off a wall, and a molotov ignites + // on the first and not the second. + FloorNormalZ float64 `json:"floor_normal_z"` + // RestSpeed and RestSteps are the sleep condition: this slow, in contact + // with the ground, for this many consecutive steps. The step count exists + // so one grazing touch on the way past is not read as having landed. + RestSpeed float64 `json:"rest_speed"` + RestSteps int `json:"rest_steps"` + // MaxFlightSeconds bounds the integration. A high lob that bounces a few + // times and then rolls takes eight or nine seconds to settle in this model, + // so the cap has to sit well clear of that; a flight still moving at it is + // falling out of the map or wedged somewhere the model cannot settle, and + // is reported unresolved rather than guessed at. + MaxFlightSeconds float64 `json:"max_flight_seconds"` + // HEFuseSeconds and FlashFuseSeconds are how long after leaving the hand + // those two detonate — in the air, on the ground, wherever they are. + HEFuseSeconds float64 `json:"he_fuse_seconds"` + FlashFuseSeconds float64 `json:"flash_fuse_seconds"` + // MolotovArmSeconds is the delay before a molotov will ignite on ground + // contact; before it, an early clip of the thrower's own feet does not + // count. + MolotovArmSeconds float64 `json:"molotov_arm_seconds"` + // EnclosureProbe is how far the six axis rays are cast when deciding a + // resolved point is inside geometry rather than in the world. A pocket + // smaller than this on every axis is not somewhere a grenade can be. + EnclosureProbe float64 `json:"enclosure_probe"` + // WorldMargin is how far outside the mesh's own bounding box a flight may + // stray before it is called out of the world. + WorldMargin float64 `json:"world_margin"` +} + +// DefaultConstants is the shipped model. See the Constants doc: approximations +// chosen for self-consistency, not measurements. +func DefaultConstants() Constants { + return Constants{ + TimeStep: 1.0 / 128.0, + Gravity: 320.0, + Radius: 2.0, + Restitution: 0.45, + Friction: 0.30, + ContactNormalSpeed: 20.0, + RollingFriction: 3.0, + FloorNormalZ: 0.7, + RestSpeed: 20.0, + RestSteps: 4, + MaxFlightSeconds: 15.0, + HEFuseSeconds: 1.5, + FlashFuseSeconds: 1.5, + MolotovArmSeconds: 0.1, + EnclosureProbe: 6.0, + WorldMargin: 1024.0, + } +} + +// ConstantOverrides is a partial Constants, as a request may carry it. Nil +// fields keep the default, so a caller can move one knob without restating the +// model — and so adding a knob does not break existing callers. +type ConstantOverrides struct { + TimeStep *float64 `json:"time_step,omitempty"` + Gravity *float64 `json:"gravity,omitempty"` + Radius *float64 `json:"radius,omitempty"` + Restitution *float64 `json:"restitution,omitempty"` + Friction *float64 `json:"friction,omitempty"` + ContactNormalSpeed *float64 `json:"contact_normal_speed,omitempty"` + RollingFriction *float64 `json:"rolling_friction,omitempty"` + FloorNormalZ *float64 `json:"floor_normal_z,omitempty"` + RestSpeed *float64 `json:"rest_speed,omitempty"` + RestSteps *int `json:"rest_steps,omitempty"` + MaxFlightSeconds *float64 `json:"max_flight_seconds,omitempty"` + HEFuseSeconds *float64 `json:"he_fuse_seconds,omitempty"` + FlashFuseSeconds *float64 `json:"flash_fuse_seconds,omitempty"` + MolotovArmSeconds *float64 `json:"molotov_arm_seconds,omitempty"` + EnclosureProbe *float64 `json:"enclosure_probe,omitempty"` + WorldMargin *float64 `json:"world_margin,omitempty"` +} + +// Apply lays the overrides over a base set. +func (o *ConstantOverrides) Apply(base Constants) Constants { + if o == nil { + return base + } + set := func(dst *float64, src *float64) { + if src != nil { + *dst = *src + } + } + set(&base.TimeStep, o.TimeStep) + set(&base.Gravity, o.Gravity) + set(&base.Radius, o.Radius) + set(&base.Restitution, o.Restitution) + set(&base.Friction, o.Friction) + set(&base.ContactNormalSpeed, o.ContactNormalSpeed) + set(&base.RollingFriction, o.RollingFriction) + set(&base.FloorNormalZ, o.FloorNormalZ) + set(&base.RestSpeed, o.RestSpeed) + set(&base.MaxFlightSeconds, o.MaxFlightSeconds) + set(&base.HEFuseSeconds, o.HEFuseSeconds) + set(&base.FlashFuseSeconds, o.FlashFuseSeconds) + set(&base.MolotovArmSeconds, o.MolotovArmSeconds) + set(&base.EnclosureProbe, o.EnclosureProbe) + set(&base.WorldMargin, o.WorldMargin) + if o.RestSteps != nil { + base.RestSteps = *o.RestSteps + } + return base +} + +// maxContactsPerStep bounds how many surfaces one step may resolve against. A +// grenade in a tight corner can legitimately touch two or three; past that it +// is wedged, and the remaining motion for the step is dropped rather than +// looped on. +const maxContactsPerStep = 4 + +// maxSimulationSteps is a hard ceiling on the integration independent of +// MaxFlightSeconds and TimeStep, so a request cannot ask for a billion steps by +// naming a tiny timestep. +const maxSimulationSteps = 1 << 16 + +// Validate rejects a constant set the integrator cannot run. It is a separate +// method so a caller supplying overrides gets one clear error rather than a +// simulation that quietly does nothing. +func (c Constants) Validate() error { + switch { + case !(c.TimeStep > 0) || c.TimeStep > 1: + return errors.New("time_step must be > 0 and <= 1 second") + case !(c.MaxFlightSeconds > 0) || c.MaxFlightSeconds > 120: + return errors.New("max_flight_seconds must be > 0 and <= 120") + case c.MaxFlightSeconds/c.TimeStep > maxSimulationSteps: + return fmt.Errorf("max_flight_seconds / time_step is more than %d steps", maxSimulationSteps) + case c.Gravity < 0: + return errors.New("gravity must not be negative") + case c.Radius < 0: + return errors.New("radius must not be negative") + case c.Restitution < 0 || c.Restitution > 1: + return errors.New("restitution must be in [0, 1]") + case c.Friction < 0 || c.Friction > 1: + return errors.New("friction must be in [0, 1]") + case c.RollingFriction < 0: + return errors.New("rolling_friction must not be negative") + case c.FloorNormalZ < -1 || c.FloorNormalZ > 1: + return errors.New("floor_normal_z must be in [-1, 1]") + case c.RestSpeed < 0 || c.ContactNormalSpeed < 0: + return errors.New("rest_speed and contact_normal_speed must not be negative") + case c.RestSteps < 1: + return errors.New("rest_steps must be at least 1") + case c.EnclosureProbe < 0 || c.WorldMargin < 0: + return errors.New("enclosure_probe and world_margin must not be negative") + } + return nil +} + +func (c Constants) fuseFor(t NadeType) (float64, bool) { + switch t { + case HE: + return c.HEFuseSeconds, true + case Flash: + return c.FlashFuseSeconds, true + } + return 0, false +} + +// Point is a world position in raw CS2 source units (Z up), matching the wire +// shape the other map endpoints use. r3.Vector is the internal type; this is +// the one that crosses the wire, because r3.Vector marshals to X/Y/Z and every +// other endpoint here speaks x/y/z. +type Point struct { + X float64 `json:"x"` + Y float64 `json:"y"` + Z float64 `json:"z"` +} + +func (p Point) vec() r3.Vector { return r3.Vector{X: p.X, Y: p.Y, Z: p.Z} } + +func pointOf(v r3.Vector) Point { return Point{X: v.X, Y: v.Y, Z: v.Z} } + +// Seed is the throw a lineup records: where the grenade left the hand and how +// fast, which together with the map is everything the flight depends on. +type Seed struct { + Type NadeType + Position r3.Vector + Velocity r3.Vector +} + +// StopReason is why the integration ended. +type StopReason string + +const ( + // StopRest — the grenade settled on the ground. Smokes and decoys. + StopRest StopReason = "rest" + // StopFuse — the timer ran out, in the air or on the ground. HE and flash. + StopFuse StopReason = "fuse" + // StopIgnite — a molotov touched ground after its arm delay. + StopIgnite StopReason = "ignite" + // StopInsideGeometry — the flight ended somewhere a grenade cannot be: + // every direction is walled within a few units. Against a new mesh this is + // the signature of a lineup the map update sealed off. + StopInsideGeometry StopReason = "inside_geometry" + // StopStartSealed — the throw ORIGIN is inside geometry. Against a new + // mesh, something was built where the player used to stand. + StopStartSealed StopReason = "start_sealed" + // StopOutOfWorld — the flight left the map's bounding box. Usually a seed + // that does not belong to this map at all. + StopOutOfWorld StopReason = "out_of_world" + // StopMaxFlight — still moving when the clock ran out. The model could not + // settle it, so it has no opinion about where it went. + StopMaxFlight StopReason = "max_flight" +) + +// ComparableOutcome is the end of one simulated flight. +// +// Read it in pairs. A single outcome is not where a grenade lands — see the +// package doc. Two outcomes from the same Seed and Constants against two meshes +// differ only where the meshes do, and that difference is the product. +type ComparableOutcome struct { + // ComparisonPoint is where this model's flight ended, in source units. It + // is named for the only thing it may be used for. It is NOT a landing + // spot, NOT a detonation position, and must never be rendered to a user as + // either. + ComparisonPoint Point `json:"comparison_point"` + // Resolved is whether the flight reached a definite end (rest, fuse or + // ignition) somewhere a grenade can actually be. An unresolved outcome + // carries a ComparisonPoint anyway, for debugging, and it means nothing. + Resolved bool `json:"resolved"` + Stop StopReason `json:"stop"` + Bounces int `json:"bounces"` + // FlightSeconds is simulated time, a multiple of TimeStep. + FlightSeconds float64 `json:"flight_seconds"` + Steps int `json:"steps"` +} + +// ErrNoMesh is returned when there is no collision mesh to simulate against. +// Without geometry a flight has nothing to bounce off, so the answer is not +// "it flew forever" but "this cannot be asked". +var ErrNoMesh = errors.New("no collision mesh to simulate against") + +// SimulateForComparison integrates one grenade flight against one mesh. +// +// The name is the warning: this function's output exists to be subtracted from +// another run of the same function against a different mesh. On its own it is +// a plausible-looking number with unquantified error. See the package doc. +// +// The error is for a request that cannot be run at all (no mesh, a seed that is +// not a number, constants the integrator rejects). A flight that runs but does +// not resolve is not an error — it is an outcome with Resolved false, because +// "this lineup no longer works" is a result and not a failure. +func SimulateForComparison(mesh *geometry.Mesh, seed Seed, c Constants) (ComparableOutcome, error) { + if mesh == nil || mesh.Triangles() == 0 { + return ComparableOutcome{}, ErrNoMesh + } + if err := c.Validate(); err != nil { + return ComparableOutcome{}, err + } + if !finite(seed.Position) || !finite(seed.Velocity) { + return ComparableOutcome{}, errors.New("seed position and velocity must be finite") + } + if seed.Velocity.Norm() <= 0 { + return ComparableOutcome{}, errors.New("seed velocity must not be zero") + } + if seed.Type == "" { + return ComparableOutcome{}, errors.New("seed nade type is required") + } + + if enclosed(mesh, seed.Position, c.EnclosureProbe) { + return ComparableOutcome{ + ComparisonPoint: pointOf(seed.Position), + Stop: StopStartSealed, + }, nil + } + + lo, hi, hasBounds := mesh.Bounds() + fuse, hasFuse := c.fuseFor(seed.Type) + dt := c.TimeStep + steps := int(math.Ceil(c.MaxFlightSeconds / dt)) + + pos, vel := seed.Position, seed.Velocity + out := ComparableOutcome{} + slow := 0 + + for step := 1; step <= steps; step++ { + // Semi-implicit Euler: gravity is applied to the velocity first and the + // position is moved at the new velocity. Fixed order, fixed step — this + // is the whole reason two runs agree to the bit. + vel.Z -= c.Gravity * dt + var contact contactInfo + pos, vel, contact = advance(mesh, pos, vel, dt, c) + out.Bounces += contact.bounces + out.Steps = step + out.FlightSeconds = float64(step) * dt + out.ComparisonPoint = pointOf(pos) + + if !finite(pos) || (hasBounds && outsideBounds(pos, lo, hi, c.WorldMargin)) { + out.Stop = StopOutOfWorld + return out, nil + } + // A fuse beats everything: an HE at rest still goes off on time. + if hasFuse && out.FlightSeconds >= fuse { + out.Stop, out.Resolved = StopFuse, true + break + } + if seed.Type == Molotov && contact.floor && out.FlightSeconds >= c.MolotovArmSeconds { + out.Stop, out.Resolved = StopIgnite, true + break + } + if contact.floor && vel.Norm() < c.RestSpeed { + slow++ + } else { + slow = 0 + } + if slow >= c.RestSteps { + out.Stop, out.Resolved = StopRest, true + break + } + } + if !out.Resolved { + if out.Stop == "" { + out.Stop = StopMaxFlight + } + return out, nil + } + // A resolved flight still has to have resolved somewhere a grenade can be. + // This is the check that catches a lineup the map update walled off: the + // flight runs fine and comes to rest inside the new geometry. + if enclosed(mesh, pos, c.EnclosureProbe) { + out.Stop, out.Resolved = StopInsideGeometry, false + } + return out, nil +} + +// contactInfo is what one step's collision resolution reports back. +type contactInfo struct { + bounces int + // floor is whether any contact this step was with a surface facing up + // enough to stand on. Rest and molotov ignition both key off it; a wall is + // not somewhere a grenade lands. + floor bool + // resting is whether any contact this step was a settling one rather than + // a bounce, which is what earns the rolling-friction decay. + resting bool +} + +// advance moves the grenade for one timestep, resolving up to +// maxContactsPerStep surfaces along the way. +func advance(mesh *geometry.Mesh, pos, vel r3.Vector, dt float64, c Constants) (r3.Vector, r3.Vector, contactInfo) { + var info contactInfo + remaining := dt + for i := 0; i < maxContactsPerStep; i++ { + speed := vel.Norm() + if remaining <= 0 || speed <= 0 { + break + } + dist := speed * remaining + dir := vel.Mul(1 / speed) + hit, ok := mesh.RayHitSurface(pos, dir) + if !ok || hit.Distance > dist+c.Radius { + pos = pos.Add(dir.Mul(dist)) + remaining = 0 + break + } + // Stop a radius short of the surface. Exact head-on, early on a + // glancing hit — and consistently so, which is what matters here. + travel := hit.Distance - c.Radius + if travel < 0 { + travel = 0 + } + if travel > dist { + travel = dist + } + pos = pos.Add(dir.Mul(travel)) + remaining -= travel / speed + + n := hit.Normal // oriented against dir, so vn below is never positive + if n.Z >= c.FloorNormalZ { + info.floor = true + } + vn := vel.Dot(n) + vt := vel.Sub(n.Mul(vn)) + if -vn < c.ContactNormalSpeed { + // Settling onto the surface: drop the normal component instead of + // reflecting it. Reflecting a near-zero approach speed is what + // makes a grenade jitter forever on a floor. + vel = vt + info.resting = true + } else { + vel = vt.Mul(1 - c.Friction).Sub(n.Mul(vn * c.Restitution)) + info.bounces++ + } + } + if info.resting { + // Per second, not per contact: a grenade in continuous contact touches + // down once per step, and charging it a bounce's worth of friction each + // time would glue it to the first flat surface it found. + decay := 1 - c.RollingFriction*dt + if decay < 0 { + decay = 0 + } + vel = vel.Mul(decay) + } + return pos, vel, info +} + +// axes are the six directions the enclosure probe casts along. Fixed order, so +// the early exit below cannot make the answer depend on anything. +var axes = [6]r3.Vector{ + {X: 1}, {X: -1}, {Y: 1}, {Y: -1}, {Z: 1}, {Z: -1}, +} + +// enclosed reports whether a point is buried in geometry: every one of the six +// axes hits a surface within probe units, so there is no room around it for a +// grenade to be. +// +// This is a cheap stand-in for a real inside/outside test, which a collision +// mesh cannot support anyway — the .tri sets are soups of triangles, not closed +// solids, so ray parity says nothing. It answers the question that actually +// matters ("is there space here") rather than the one that does not ("is this +// point within a volume"). +func enclosed(mesh *geometry.Mesh, at r3.Vector, probe float64) bool { + if probe <= 0 { + return false + } + for _, d := range axes { + hit, ok := mesh.RayHitSurface(at, d) + if !ok || hit.Distance > probe { + return false + } + } + return true +} + +func outsideBounds(p, lo, hi r3.Vector, margin float64) bool { + return p.X < lo.X-margin || p.X > hi.X+margin || + p.Y < lo.Y-margin || p.Y > hi.Y+margin || + p.Z < lo.Z-margin || p.Z > hi.Z+margin +} + +func finite(v r3.Vector) bool { + for _, c := range [3]float64{v.X, v.Y, v.Z} { + if math.IsNaN(c) || math.IsInf(c, 0) { + return false + } + } + return true +} diff --git a/internal/simulate/flight_test.go b/internal/simulate/flight_test.go new file mode 100644 index 0000000..d6789f4 --- /dev/null +++ b/internal/simulate/flight_test.go @@ -0,0 +1,277 @@ +package simulate + +import ( + "math" + "testing" + + "github.com/golang/geo/r3" +) + +// A throw across open ground: leaves the hand at eye height, arcs out, bounces +// a few times and rolls to a stop. +var openThrow = Seed{ + Type: Smoke, + Position: r3.Vector{X: 0, Y: 0, Z: 64}, + Velocity: r3.Vector{X: 500, Y: 0, Z: 200}, +} + +func TestFlightIsDeterministic(t *testing.T) { + mesh, _ := synthRevision(t, floorQuad(0, 0, 0, 2000)) + first, err := SimulateForComparison(mesh, openThrow, DefaultConstants()) + if err != nil { + t.Fatalf("simulate: %v", err) + } + for i := 0; i < 5; i++ { + again, err := SimulateForComparison(mesh, openThrow, DefaultConstants()) + if err != nil { + t.Fatalf("simulate %d: %v", i, err) + } + if again != first { + t.Fatalf("run %d differs:\n first %+v\n again %+v", i, first, again) + } + } +} + +// The same throw against two meshes built independently from identical bytes +// must agree to the bit. Everything downstream assumes a difference in the +// output means a difference in the mesh, so a simulator that wobbled between +// two builds of the same geometry would report drift that is not there. +func TestIdenticalMeshesGiveIdenticalFlights(t *testing.T) { + tris := floorQuad(0, 0, 0, 2000) + a, _ := synthRevision(t, tris) + b, _ := synthRevision(t, tris) + if a == b { + t.Fatal("test setup: wanted two separately built meshes") + } + left, err := SimulateForComparison(a, openThrow, DefaultConstants()) + if err != nil { + t.Fatalf("simulate a: %v", err) + } + right, err := SimulateForComparison(b, openThrow, DefaultConstants()) + if err != nil { + t.Fatalf("simulate b: %v", err) + } + if left != right { + t.Fatalf("identical geometry gave different flights:\n a %+v\n b %+v", left, right) + } +} + +func TestSmokeComesToRestOnTheFloor(t *testing.T) { + c := DefaultConstants() + mesh, _ := synthRevision(t, floorQuad(0, 0, 0, 2000)) + out, err := SimulateForComparison(mesh, openThrow, c) + if err != nil { + t.Fatalf("simulate: %v", err) + } + if !out.Resolved || out.Stop != StopRest { + t.Fatalf("a smoke on open ground should come to rest, got %+v", out) + } + // The flight is integrated as a point held a radius off each surface, and a + // glancing final contact leaves it lower than a head-on one — so the resting + // height is somewhere in [0, radius], never inside the floor. + if out.ComparisonPoint.Z < 0 || out.ComparisonPoint.Z > c.Radius { + t.Fatalf("rest height %v is not within a radius above the floor", out.ComparisonPoint.Z) + } + if out.ComparisonPoint.X < 200 { + t.Fatalf("a 500 u/s throw should carry further than %v units", out.ComparisonPoint.X) + } + if out.Bounces == 0 { + t.Fatal("expected the throw to bounce at least once") + } +} + +func TestFuseTypesStopOnTheirFuse(t *testing.T) { + c := DefaultConstants() + mesh, _ := synthRevision(t, floorQuad(0, 0, 0, 2000)) + for _, tc := range []struct { + nade NadeType + fuse float64 + }{ + {HE, c.HEFuseSeconds}, + {Flash, c.FlashFuseSeconds}, + } { + seed := openThrow + seed.Type = tc.nade + out, err := SimulateForComparison(mesh, seed, c) + if err != nil { + t.Fatalf("%s: %v", tc.nade, err) + } + if !out.Resolved || out.Stop != StopFuse { + t.Fatalf("%s should detonate on its fuse, got %+v", tc.nade, out) + } + if math.Abs(out.FlightSeconds-tc.fuse) > c.TimeStep { + t.Fatalf("%s detonated at %vs, want %vs", tc.nade, out.FlightSeconds, tc.fuse) + } + } +} + +// A molotov ignites on the ground, not on a wall — otherwise every throw that +// clipped a doorframe would resolve in mid-air, and a lineup would look broken +// the moment a wall moved a unit. +func TestMolotovIgnitesOnGroundNotWall(t *testing.T) { + // A wall at x = 300 to bounce off, with the floor a long way below. + tris := append( + floorQuad(0, 0, -400, 2000), + quad( + r3.Vector{X: 300, Y: -400, Z: -400}, + r3.Vector{X: 300, Y: 400, Z: -400}, + r3.Vector{X: 300, Y: 400, Z: 400}, + r3.Vector{X: 300, Y: -400, Z: 400}, + )..., + ) + mesh, _ := synthRevision(t, tris) + seed := Seed{Type: Molotov, Position: r3.Vector{X: 0, Y: 0, Z: 0}, Velocity: r3.Vector{X: 600}} + out, err := SimulateForComparison(mesh, seed, DefaultConstants()) + if err != nil { + t.Fatalf("simulate: %v", err) + } + if !out.Resolved || out.Stop != StopIgnite { + t.Fatalf("a molotov should ignite on the floor, got %+v", out) + } + if out.ComparisonPoint.Z > -390 { + t.Fatalf("ignited at z=%v: it should have fallen to the floor, not lit on the wall", out.ComparisonPoint.Z) + } + if out.Bounces == 0 { + t.Fatal("expected the wall bounce to be counted") + } +} + +// A throw that starts inside geometry does not resolve. Against a new mesh this +// is a map update having built something where the player used to stand. +func TestSealedStartDoesNotResolve(t *testing.T) { + tris := append( + floorQuad(0, 0, 0, 2000), + box(r3.Vector{X: -4, Y: -4, Z: 60}, r3.Vector{X: 4, Y: 4, Z: 68})..., + ) + mesh, _ := synthRevision(t, tris) + out, err := SimulateForComparison(mesh, openThrow, DefaultConstants()) + if err != nil { + t.Fatalf("simulate: %v", err) + } + if out.Resolved || out.Stop != StopStartSealed { + t.Fatalf("a throw from inside a solid should not resolve, got %+v", out) + } +} + +// A flight that leaves the map is unresolved, not "landed at the bottom of the +// world". Falling out is exactly what a lineup does when the floor under it is +// removed. +func TestFallingOutOfTheWorldDoesNotResolve(t *testing.T) { + mesh, _ := synthRevision(t, floorQuad(0, 0, 0, 200)) + seed := Seed{Type: Smoke, Position: r3.Vector{X: 0, Y: 0, Z: 64}, Velocity: r3.Vector{X: 900, Z: 100}} + out, err := SimulateForComparison(mesh, seed, DefaultConstants()) + if err != nil { + t.Fatalf("simulate: %v", err) + } + if out.Resolved || out.Stop != StopOutOfWorld { + t.Fatalf("a throw off the edge of the geometry should not resolve, got %+v", out) + } +} + +// The enclosure probe is what turns "resolved" into "resolved somewhere a +// grenade can actually be". +func TestEnclosedDetectsBeingInsideASolid(t *testing.T) { + mesh, _ := synthRevision(t, box(r3.Vector{X: -10, Y: -10, Z: -10}, r3.Vector{X: 10, Y: 10, Z: 10})) + if !enclosed(mesh, r3.Vector{}, 16) { + t.Fatal("a point in the middle of a small sealed box should read as enclosed") + } + if enclosed(mesh, r3.Vector{}, 6) { + t.Fatal("a probe shorter than the walls are away should not read as enclosed") + } + open, _ := synthRevision(t, floorQuad(0, 0, 0, 2000)) + if enclosed(open, r3.Vector{Z: 4}, 64) { + t.Fatal("a point standing on open ground is not enclosed") + } +} + +func TestSimulateRejectsWhatItCannotRun(t *testing.T) { + mesh, _ := synthRevision(t, floorQuad(0, 0, 0, 2000)) + if _, err := SimulateForComparison(nil, openThrow, DefaultConstants()); err == nil { + t.Error("a nil mesh should be an error, not a flight through empty space") + } + stopped := openThrow + stopped.Velocity = r3.Vector{} + if _, err := SimulateForComparison(mesh, stopped, DefaultConstants()); err == nil { + t.Error("a zero velocity should be an error") + } + nan := openThrow + nan.Velocity = r3.Vector{X: math.NaN()} + if _, err := SimulateForComparison(mesh, nan, DefaultConstants()); err == nil { + t.Error("a non-finite velocity should be an error") + } + untyped := openThrow + untyped.Type = "" + if _, err := SimulateForComparison(mesh, untyped, DefaultConstants()); err == nil { + t.Error("a seed with no nade type should be an error") + } + bad := DefaultConstants() + bad.TimeStep = 0 + if _, err := SimulateForComparison(mesh, openThrow, bad); err == nil { + t.Error("a zero timestep should be an error") + } +} + +func TestConstantsValidate(t *testing.T) { + for _, tc := range []struct { + name string + mut func(*Constants) + }{ + {"negative timestep", func(c *Constants) { c.TimeStep = -1 }}, + {"timestep over a second", func(c *Constants) { c.TimeStep = 2 }}, + {"too many steps", func(c *Constants) { c.TimeStep, c.MaxFlightSeconds = 1e-9, 100 }}, + {"negative gravity", func(c *Constants) { c.Gravity = -1 }}, + {"restitution over one", func(c *Constants) { c.Restitution = 1.5 }}, + {"friction over one", func(c *Constants) { c.Friction = 1.5 }}, + {"zero rest steps", func(c *Constants) { c.RestSteps = 0 }}, + {"negative probe", func(c *Constants) { c.EnclosureProbe = -1 }}, + } { + c := DefaultConstants() + tc.mut(&c) + if err := c.Validate(); err == nil { + t.Errorf("%s should not validate", tc.name) + } + } + if err := DefaultConstants().Validate(); err != nil { + t.Fatalf("the shipped constants must validate: %v", err) + } +} + +func TestConstantOverridesLeaveTheRestAlone(t *testing.T) { + g := 111.0 + steps := 9 + got := (&ConstantOverrides{Gravity: &g, RestSteps: &steps}).Apply(DefaultConstants()) + if got.Gravity != g || got.RestSteps != steps { + t.Fatalf("overrides not applied: %+v", got) + } + want := DefaultConstants() + want.Gravity, want.RestSteps = g, steps + if got != want { + t.Fatalf("an override changed something it was not asked to:\n got %+v\nwant %+v", got, want) + } + if (*ConstantOverrides)(nil).Apply(DefaultConstants()) != DefaultConstants() { + t.Fatal("nil overrides should leave the defaults alone") + } +} + +func TestParseNadeType(t *testing.T) { + for in, want := range map[string]NadeType{ + "Smoke": Smoke, + "smoke": Smoke, + "HE": HE, + "hegrenade": HE, + "Flash": Flash, + "flashbang": Flash, + "Molotov": Molotov, + "incendiary": Molotov, + "Decoy": Decoy, + " smoke ": Smoke, + } { + got, ok := ParseNadeType(in) + if !ok || got != want { + t.Errorf("ParseNadeType(%q) = %q, %v; want %q", in, got, ok, want) + } + } + if _, ok := ParseNadeType("banana"); ok { + t.Error("an unknown grenade should not parse") + } +} diff --git a/internal/simulate/helpers_test.go b/internal/simulate/helpers_test.go new file mode 100644 index 0000000..6f94b0f --- /dev/null +++ b/internal/simulate/helpers_test.go @@ -0,0 +1,326 @@ +package simulate + +import ( + "encoding/binary" + "fmt" + "math" + "math/rand" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/5stackgg/demo-parser/internal/geometry" + "github.com/golang/geo/r3" +) + +// tri is one triangle's three corners, and quad is the two triangles of a +// rectangle — the only two shapes the synthetic meshes here are built from. +type tri = [3]r3.Vector + +func quad(a, b, c, d r3.Vector) []tri { + return []tri{{a, b, c}, {a, c, d}} +} + +// floorQuad is a horizontal square at height z, centred on (cx, cy). +func floorQuad(cx, cy, z, half float64) []tri { + return quad( + r3.Vector{X: cx - half, Y: cy - half, Z: z}, + r3.Vector{X: cx + half, Y: cy - half, Z: z}, + r3.Vector{X: cx + half, Y: cy + half, Z: z}, + r3.Vector{X: cx - half, Y: cy + half, Z: z}, + ) +} + +// box is the six faces of an axis-aligned box, which is how a test builds +// either an obstacle to bounce off or a sealed pocket to be buried in. +func box(lo, hi r3.Vector) []tri { + p := [8]r3.Vector{ + {X: lo.X, Y: lo.Y, Z: lo.Z}, {X: hi.X, Y: lo.Y, Z: lo.Z}, + {X: hi.X, Y: hi.Y, Z: lo.Z}, {X: lo.X, Y: hi.Y, Z: lo.Z}, + {X: lo.X, Y: lo.Y, Z: hi.Z}, {X: hi.X, Y: lo.Y, Z: hi.Z}, + {X: hi.X, Y: hi.Y, Z: hi.Z}, {X: lo.X, Y: hi.Y, Z: hi.Z}, + } + var out []tri + out = append(out, quad(p[0], p[1], p[2], p[3])...) // bottom + out = append(out, quad(p[4], p[5], p[6], p[7])...) // top + out = append(out, quad(p[0], p[1], p[5], p[4])...) // -Y + out = append(out, quad(p[3], p[2], p[6], p[7])...) // +Y + out = append(out, quad(p[0], p[3], p[7], p[4])...) // -X + out = append(out, quad(p[1], p[2], p[6], p[5])...) // +X + return out +} + +// triBlob serializes triangles into the .tri wire format (9 LE float32 each), +// so a synthetic mesh goes through exactly the loader a shipped one does. +func triBlob(tris ...tri) []byte { + buf := make([]byte, 0, len(tris)*9*4) + put := func(f float64) { + var p [4]byte + binary.LittleEndian.PutUint32(p[:], math.Float32bits(float32(f))) + buf = append(buf, p[:]...) + } + for _, t := range tris { + for _, v := range t { + put(v.X) + put(v.Y) + put(v.Z) + } + } + return buf +} + +// meshRevisionServer stands in for one tagged mesh revision on the CDN, serving +// the named .tri blobs. It returns a base URL usable as a mesh reference, which +// is how a test gets two independently built meshes resident at once. +func meshRevisionServer(t testing.TB, files map[string][]byte) string { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + blob, ok := files[filepath.Base(r.URL.Path)] + if !ok { + w.WriteHeader(http.StatusNotFound) + return + } + _, _ = w.Write(blob) + })) + t.Cleanup(srv.Close) + return srv.URL +} + +func loadMesh(t testing.TB, base, name string) *geometry.Mesh { + t.Helper() + mesh, err := geometry.LoadRevision(name, base) + if err != nil { + t.Fatalf("load %s from %s: %v", name, base, err) + } + if mesh == nil { + t.Fatalf("no mesh for %s at %s", name, base) + } + return mesh +} + +// synthRevision publishes one map ("de_test") built from the given triangles +// and returns both the mesh and the revision reference that produced it. +func synthRevision(t testing.TB, tris []tri) (*geometry.Mesh, string) { + t.Helper() + base := meshRevisionServer(t, map[string][]byte{"de_test.tri": triBlob(tris...)}) + return loadMesh(t, base, "de_test"), base +} + +// localMeshDir finds the replay-map-meshes clone checked out above this repo, +// the same way the endpoint tests do. Real geometry rather than a box: a +// bounce off a shipped mesh crosses triangle seams, which is exactly where a +// sloppy simulator would stop being reproducible. +func localMeshDir(t testing.TB) string { + t.Helper() + if dir := os.Getenv("MAP_MESH_FIXTURES"); dir != "" { + return dir + } + wd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + for d := wd; ; { + candidate := filepath.Join(d, "replay-map-meshes") + if _, err := os.Stat(filepath.Join(candidate, "de_mirage.tri")); err == nil { + return candidate + } + parent := filepath.Dir(d) + if parent == d { + return "" + } + d = parent + } +} + +// realMeshRevision serves the local clone as a mesh revision. Each call is a +// separate server, so asking twice yields two independently built meshes of +// identical geometry — the strongest form of the same-mesh property. +func realMeshRevision(t testing.TB) string { + t.Helper() + dir := localMeshDir(t) + if dir == "" { + t.Skip("no replay-map-meshes clone found above the working directory; set MAP_MESH_FIXTURES") + } + srv := httptest.NewServer(http.FileServer(http.Dir(dir))) + t.Cleanup(srv.Close) + return srv.URL +} + +func pt(x, y, z float64) r3.Vector { return r3.Vector{X: x, Y: y, Z: z} } + +// rectQuad is an axis-aligned horizontal rectangle, for building floors with +// pieces missing. +func rectQuad(x0, x1, y0, y1, z float64) []tri { + return quad( + r3.Vector{X: x0, Y: y0, Z: z}, + r3.Vector{X: x1, Y: y0, Z: z}, + r3.Vector{X: x1, Y: y1, Z: z}, + r3.Vector{X: x0, Y: y1, Z: z}, + ) +} + +// terrain is the synthetic map the drift tests throw across: open ground, a +// back wall to bounce off and a ramp to roll down, so a random throw has +// somewhere interesting to end up rather than always landing on a flat plane. +func terrain() []tri { + out := floorQuad(0, 0, 0, 2000) + out = append(out, quad( + r3.Vector{X: 1800, Y: -2000, Z: 0}, r3.Vector{X: 1800, Y: 2000, Z: 0}, + r3.Vector{X: 1800, Y: 2000, Z: 400}, r3.Vector{X: 1800, Y: -2000, Z: 400})...) + out = append(out, quad( + r3.Vector{X: -1200, Y: -600, Z: 0}, r3.Vector{X: -600, Y: -600, Z: 240}, + r3.Vector{X: -600, Y: 600, Z: 240}, r3.Vector{X: -1200, Y: 600, Z: 0})...) + return out +} + +// floorWithHole is terrain's floor with a rectangle missing at +// x∈[800,1600], y∈[-400,400] — exactly the patch floorQuad(1200, 0, 0, 400) +// covers, so the two meshes differ by that one piece and nothing else. +func floorWithHole() []tri { + out := rectQuad(-2000, 800, -2000, 2000, 0) + out = append(out, rectQuad(1600, 2000, -2000, 2000, 0)...) + out = append(out, rectQuad(800, 1600, -2000, -400, 0)...) + out = append(out, rectQuad(800, 1600, 400, 2000, 0)...) + return out +} + +// meshPair is a built mesh together with the revision reference that produced +// it, which is what a DriftRequest names. +type meshPair struct { + mesh *geometry.Mesh + ref string +} + +func newMeshPair(t testing.TB, tris []tri) *meshPair { + t.Helper() + mesh, ref := synthRevision(t, tris) + return &meshPair{mesh: mesh, ref: ref} +} + +// randomConstants walks the whole knob space, well past anything plausible. +// The same-mesh property has to hold for a model that is wrong in any way at +// all, so the test does not get to assume the constants are sensible. +func randomConstants(rng *rand.Rand) Constants { + pick := func(lo, hi float64) float64 { return lo + rng.Float64()*(hi-lo) } + return Constants{ + TimeStep: 1 / pick(48, 300), + Gravity: pick(100, 800), + Radius: pick(0, 8), + Restitution: pick(0, 0.95), + Friction: pick(0, 0.9), + ContactNormalSpeed: pick(0, 80), + RollingFriction: pick(0, 12), + FloorNormalZ: pick(0.2, 0.95), + RestSpeed: pick(2, 60), + RestSteps: 1 + rng.Intn(8), + MaxFlightSeconds: pick(8, 20), + HEFuseSeconds: pick(0.3, 3), + FlashFuseSeconds: pick(0.3, 3), + MolotovArmSeconds: pick(0, 1), + EnclosureProbe: pick(0, 24), + WorldMargin: pick(64, 2048), + } +} + +// overridesFrom turns a full constant set into the override form a request +// carries, so a test can push an arbitrary model through the request path. +func overridesFrom(c Constants) *ConstantOverrides { + return &ConstantOverrides{ + TimeStep: &c.TimeStep, Gravity: &c.Gravity, Radius: &c.Radius, + Restitution: &c.Restitution, Friction: &c.Friction, + ContactNormalSpeed: &c.ContactNormalSpeed, RollingFriction: &c.RollingFriction, + FloorNormalZ: &c.FloorNormalZ, RestSpeed: &c.RestSpeed, RestSteps: &c.RestSteps, + MaxFlightSeconds: &c.MaxFlightSeconds, HEFuseSeconds: &c.HEFuseSeconds, + FlashFuseSeconds: &c.FlashFuseSeconds, MolotovArmSeconds: &c.MolotovArmSeconds, + EnclosureProbe: &c.EnclosureProbe, WorldMargin: &c.WorldMargin, + } +} + +// randomLineups is a batch of throws in every direction, including a few with +// no recorded seed — the library is full of those and they have to survive the +// same code path. +func randomLineups(rng *rand.Rand, n int) []LineupSeed { + types := []string{"Smoke", "HE", "Flash", "Molotov", "Decoy"} + out := make([]LineupSeed, 0, n) + for i := 0; i < n; i++ { + l := LineupSeed{ID: fmt.Sprintf("lineup-%d", i), NadeType: types[rng.Intn(len(types))]} + if i%7 != 3 { + yaw := rng.Float64() * 2 * math.Pi + speed := 100 + rng.Float64()*800 + l.InitialPosition = &Point{ + X: rng.Float64()*800 - 400, + Y: rng.Float64()*800 - 400, + Z: 32 + rng.Float64()*64, + } + l.InitialVelocity = &Point{ + X: speed * math.Cos(yaw), + Y: speed * math.Sin(yaw), + Z: rng.Float64()*500 - 150, + } + } + out = append(out, l) + } + return out +} + +func sameDrift(a, b LineupDrift) bool { + if a.Index != b.Index || a.ID != b.ID || a.Verdict != b.Verdict || + a.Reason != b.Reason || a.Severity != b.Severity { + return false + } + if !sameOutcome(a.From, b.From) || !sameOutcome(a.To, b.To) { + return false + } + return sameFloat(a.Distance, b.Distance) && + sameFloat(a.DistanceXY, b.DistanceXY) && + sameFloat(a.DistanceZ, b.DistanceZ) +} + +func sameOutcome(a, b *ComparableOutcome) bool { + if a == nil || b == nil { + return a == b + } + return *a == *b +} + +func sameFloat(a, b *float64) bool { + if a == nil || b == nil { + return a == b + } + return *a == *b +} + +// terrainWithPlatform is terrain with the ground raised where the open throw +// used to land — a map update dropping a block under a lineup. +func terrainWithPlatform() []tri { + return append(append([]tri(nil), terrain()...), + box(pt(400, -1200, 0), pt(1799, 1200, 64))...) +} + +// chamber is a walled room with a low front wall, so a nade lobbed in from +// outside clears the wall on the way in and is boxed in on every side once it +// settles. With the ceiling on, a landing inside has no space around it at all +// — which is what the enclosure probe is looking for. +// +// The ceiling sits at z=200 and the flight never rises past 130 inside the +// room, so adding it changes where the grenade ENDS UP being, and not one step +// of how it got there. +func chamber(withCeiling bool) []tri { + const ( + x0, x1 = 0.0, 300.0 + y0, y1 = -150.0, 150.0 + top = 200.0 + front = 100.0 + ) + out := rectQuad(x0, x1, y0, y1, 0) + out = append(out, quad(pt(x1, y0, 0), pt(x1, y1, 0), pt(x1, y1, top), pt(x1, y0, top))...) + out = append(out, quad(pt(x0, y0, 0), pt(x1, y0, 0), pt(x1, y0, top), pt(x0, y0, top))...) + out = append(out, quad(pt(x0, y1, 0), pt(x1, y1, 0), pt(x1, y1, top), pt(x0, y1, top))...) + out = append(out, quad(pt(x0, y0, 0), pt(x0, y1, 0), pt(x0, y1, front), pt(x0, y0, front))...) + if withCeiling { + out = append(out, rectQuad(x0, x1, y0, y1, top)...) + } + return out +}