From 1d26988cc90f84d1ce8a6bd9d0dbdca7437e8002 Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Wed, 4 Feb 2026 12:17:20 +0100 Subject: [PATCH 1/3] feat: add projectile marker support for grenades and smokes Thrown projectiles (grenades, smokes) are now exported as markers in the v1 JSON format instead of fire lines. This enables the web viewer to display grenade/smoke icons with trajectory positions. Changes: - Add ProjectileEventToProjectileMarker converter that extracts icon filename from Arma path and creates marker with trajectory states - Modify handleProjectileEvent to route thrown projectiles (Weapon="throw") to AddMarker instead of RecordFiredEvent - Add tests for projectile marker conversion The marker format follows web viewer expectations: - Type: magIcons/.paa - Side: GLOBAL (-1) - OwnerID: firer's OCAP ID - Positions: trajectory from throw to impact --- internal/model/convert/convert.go | 95 ++++++++++++++++++++++++++ internal/model/convert/convert_test.go | 86 +++++++++++++++++++++++ internal/worker/dispatch.go | 17 ++++- 3 files changed, 195 insertions(+), 3 deletions(-) diff --git a/internal/model/convert/convert.go b/internal/model/convert/convert.go index 0485523..cff952a 100644 --- a/internal/model/convert/convert.go +++ b/internal/model/convert/convert.go @@ -3,6 +3,7 @@ package convert import ( "encoding/json" + "fmt" "github.com/OCAP2/extension/v5/internal/model" "github.com/OCAP2/extension/v5/internal/model/core" @@ -405,6 +406,100 @@ func ProjectileEventToFiredEvent(p model.ProjectileEvent) core.FiredEvent { } } +// ProjectileEventToProjectileMarker converts a thrown projectile (grenade, smoke) to a marker +// for the web viewer. Returns the marker and any state changes for trajectory positions. +// Only call this for projectiles where Weapon == "throw". +func ProjectileEventToProjectileMarker(p model.ProjectileEvent) (core.Marker, []core.MarkerState) { + // Extract filename from icon path: "\A3\...\gear_smokegrenade_white_ca.paa" → "magIcons/gear_smokegrenade_white_ca.paa" + // Handle both forward and backslash separators (Arma uses backslashes) + iconPath := p.MagazineIcon + // Find last separator (either / or \) + lastSep := -1 + for i := len(iconPath) - 1; i >= 0; i-- { + if iconPath[i] == '/' || iconPath[i] == '\\' { + lastSep = i + break + } + } + var iconFilename string + if lastSep >= 0 { + iconFilename = iconPath[lastSep+1:] + } else { + iconFilename = iconPath + } + markerType := "magIcons/" + iconFilename + if iconFilename == "" { + // Fallback for missing icon + markerType = "magIcons/gear_unknown_ca.paa" + } + + // Generate unique marker name and ID + // ID combines frame and firer to ensure uniqueness + markerName := fmt.Sprintf("projectile_%d_%d", p.CaptureFrame, p.FirerObjectID) + markerID := uint(p.CaptureFrame)<<16 | uint(p.FirerObjectID) + + var positions []core.Position3D + var frames []uint + + // Extract positions from the LineStringZM geometry + // Format: [x, y, z, tickTime] where we use tickTime's frame approximation + if !p.Positions.IsEmpty() { + if ls, ok := p.Positions.AsLineString(); ok { + seq := ls.Coordinates() + for i := 0; i < seq.Length(); i++ { + pt := seq.Get(i) + positions = append(positions, core.Position3D{X: pt.X, Y: pt.Y, Z: pt.Z}) + // M coordinate contains tickTime, but we need frame numbers + // The frame numbers are embedded in the original positions array from SQF + // For now, estimate frame offset from start + frames = append(frames, p.CaptureFrame+uint(i)) + } + } + } + + // Use first position for the marker, rest become states + var firstPos core.Position3D + if len(positions) > 0 { + firstPos = positions[0] + } + + marker := core.Marker{ + ID: markerID, + MissionID: p.MissionID, + CaptureFrame: p.CaptureFrame, + MarkerName: markerName, + MarkerType: markerType, + Text: p.MagazineDisplay, + OwnerID: int(p.FirerObjectID), + Color: "FFFFFF", + Side: "GLOBAL", + Position: firstPos, + Size: "[1,1]", + Shape: "ICON", + Alpha: 1.0, + Brush: "Solid", + } + + // Create states for remaining positions (trajectory) + var states []core.MarkerState + for i := 1; i < len(positions); i++ { + frame := frames[i] + if i < len(frames) { + frame = frames[i] + } + states = append(states, core.MarkerState{ + MarkerID: markerID, + MissionID: p.MissionID, + CaptureFrame: frame, + Position: positions[i], + Direction: 0, + Alpha: 1.0, + }) + } + + return marker, states +} + // MissionToCore converts a GORM Mission to a core.Mission func MissionToCore(m *model.Mission) core.Mission { addons := make([]core.Addon, 0, len(m.Addons)) diff --git a/internal/model/convert/convert_test.go b/internal/model/convert/convert_test.go index f4095d8..ea56787 100644 --- a/internal/model/convert/convert_test.go +++ b/internal/model/convert/convert_test.go @@ -705,6 +705,92 @@ func TestProjectileEventToFiredEvent_EmptyPositions(t *testing.T) { } } +func TestProjectileEventToProjectileMarker(t *testing.T) { + // Create a LineStringZM with 3 points (thrown, mid-flight, impact) + coords := []float64{ + 100.0, 200.0, 10.0, 1000.0, // thrown position + 150.0, 250.0, 15.0, 1001.0, // mid-flight + 200.0, 300.0, 5.0, 1002.0, // impact position + } + seq := geom.NewSequence(coords, geom.DimXYZM) + ls, _ := geom.NewLineString(seq) + + gormEvent := model.ProjectileEvent{ + MissionID: 1, + FirerObjectID: 42, + CaptureFrame: 100, + Weapon: "throw", + MagazineDisplay: "Smoke Grenade (White)", + MagazineIcon: `\A3\Weapons_F\Data\UI\gear_smokegrenade_white_ca.paa`, + Positions: ls.AsGeometry(), + } + + marker, states := ProjectileEventToProjectileMarker(gormEvent) + + // Check marker fields + if marker.MarkerType != "magIcons/gear_smokegrenade_white_ca.paa" { + t.Errorf("expected MarkerType=magIcons/gear_smokegrenade_white_ca.paa, got %s", marker.MarkerType) + } + if marker.Text != "Smoke Grenade (White)" { + t.Errorf("expected Text=Smoke Grenade (White), got %s", marker.Text) + } + if marker.OwnerID != 42 { + t.Errorf("expected OwnerID=42, got %d", marker.OwnerID) + } + if marker.Side != "GLOBAL" { + t.Errorf("expected Side=GLOBAL, got %s", marker.Side) + } + if marker.Shape != "ICON" { + t.Errorf("expected Shape=ICON, got %s", marker.Shape) + } + if marker.MarkerName != "projectile_100_42" { + t.Errorf("expected MarkerName=projectile_100_42, got %s", marker.MarkerName) + } + + // First position should be in marker + if marker.Position.X != 100.0 || marker.Position.Y != 200.0 { + t.Errorf("expected marker Position=(100,200), got (%f,%f)", + marker.Position.X, marker.Position.Y) + } + + // Remaining positions should be in states + if len(states) != 2 { + t.Fatalf("expected 2 states, got %d", len(states)) + } + if states[0].Position.X != 150.0 || states[0].Position.Y != 250.0 { + t.Errorf("expected state[0] Position=(150,250), got (%f,%f)", + states[0].Position.X, states[0].Position.Y) + } + if states[1].Position.X != 200.0 || states[1].Position.Y != 300.0 { + t.Errorf("expected state[1] Position=(200,300), got (%f,%f)", + states[1].Position.X, states[1].Position.Y) + } + + // States should reference marker ID + if states[0].MarkerID != marker.ID { + t.Errorf("expected state MarkerID=%d, got %d", marker.ID, states[0].MarkerID) + } +} + +func TestProjectileEventToProjectileMarker_EmptyIcon(t *testing.T) { + gormEvent := model.ProjectileEvent{ + MissionID: 1, + FirerObjectID: 5, + CaptureFrame: 50, + Weapon: "throw", + MagazineDisplay: "Unknown Grenade", + MagazineIcon: "", // empty icon + Positions: geom.Geometry{}, + } + + marker, _ := ProjectileEventToProjectileMarker(gormEvent) + + // Should use fallback icon + if marker.MarkerType != "magIcons/gear_unknown_ca.paa" { + t.Errorf("expected fallback MarkerType=magIcons/gear_unknown_ca.paa, got %s", marker.MarkerType) + } +} + // Compile-time interface checks var ( _ core.Soldier = SoldierToCore(model.Soldier{}) diff --git a/internal/worker/dispatch.go b/internal/worker/dispatch.go index c500f8a..de89eaa 100644 --- a/internal/worker/dispatch.go +++ b/internal/worker/dispatch.go @@ -149,14 +149,25 @@ func (m *Manager) handleFiredEvent(e dispatcher.Event) (any, error) { } func (m *Manager) handleProjectileEvent(e dispatcher.Event) (any, error) { - // For memory backend, convert projectile to fired event (simpler format) + // For memory backend, convert projectile to appropriate format if m.hasBackend() { obj, err := m.deps.HandlerService.LogProjectileEvent(e.Args) if err != nil { return nil, fmt.Errorf("failed to log projectile event: %w", err) } - coreObj := convert.ProjectileEventToFiredEvent(obj) - m.backend.RecordFiredEvent(&coreObj) + + // Thrown projectiles (grenades, smokes) become markers + if obj.Weapon == "throw" { + marker, states := convert.ProjectileEventToProjectileMarker(obj) + m.backend.AddMarker(&marker) + for i := range states { + m.backend.RecordMarkerState(&states[i]) + } + } else { + // Other projectiles become fire lines + coreObj := convert.ProjectileEventToFiredEvent(obj) + m.backend.RecordFiredEvent(&coreObj) + } return nil, nil } From a8d561ae148ba45edb5b6833330c14f0420d006f Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Wed, 4 Feb 2026 12:33:14 +0100 Subject: [PATCH 2/3] fix: use frame numbers instead of tickTime in projectile positions The M coordinate in LineStringZM now stores the actual frame number from SQF instead of tickTime. This enables accurate frame-by-frame animation of projectile markers in the web viewer. Before: M = tickTime (unused) After: M = frameNo (used for marker state CaptureFrame) --- internal/handlers/handlers.go | 9 +++++---- internal/model/convert/convert.go | 8 +++----- internal/model/convert/convert_test.go | 15 ++++++++++++--- 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index aed1683..716b648 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -772,10 +772,10 @@ func (s *Service) LogProjectileEvent(data []string) (model.ProjectileEvent, erro continue } - // posArr[0] = tickTime (float) - tickTime, ok := posArr[0].(float64) + // posArr[1] = frameNo (float64 from JSON) + frameNo, ok := posArr[1].(float64) if !ok { - logger.Warn("Invalid tickTime in position", "value", posArr[0]) + logger.Warn("Invalid frameNo in position", "value", posArr[1]) continue } @@ -793,12 +793,13 @@ func (s *Service) LogProjectileEvent(data []string) (model.ProjectileEvent, erro } coords, _ := point.Coordinates() + // Store as XYZM where M = frame number (used by projectile markers) positionSequence = append( positionSequence, coords.XY.X, coords.XY.Y, coords.Z, - tickTime, + frameNo, ) } diff --git a/internal/model/convert/convert.go b/internal/model/convert/convert.go index cff952a..fbb9dfd 100644 --- a/internal/model/convert/convert.go +++ b/internal/model/convert/convert.go @@ -442,17 +442,15 @@ func ProjectileEventToProjectileMarker(p model.ProjectileEvent) (core.Marker, [] var frames []uint // Extract positions from the LineStringZM geometry - // Format: [x, y, z, tickTime] where we use tickTime's frame approximation + // Format: [x, y, z, frameNo] where M coordinate contains the frame number if !p.Positions.IsEmpty() { if ls, ok := p.Positions.AsLineString(); ok { seq := ls.Coordinates() for i := 0; i < seq.Length(); i++ { pt := seq.Get(i) positions = append(positions, core.Position3D{X: pt.X, Y: pt.Y, Z: pt.Z}) - // M coordinate contains tickTime, but we need frame numbers - // The frame numbers are embedded in the original positions array from SQF - // For now, estimate frame offset from start - frames = append(frames, p.CaptureFrame+uint(i)) + // M coordinate contains the frame number + frames = append(frames, uint(pt.M)) } } } diff --git a/internal/model/convert/convert_test.go b/internal/model/convert/convert_test.go index ea56787..d22c65d 100644 --- a/internal/model/convert/convert_test.go +++ b/internal/model/convert/convert_test.go @@ -707,10 +707,11 @@ func TestProjectileEventToFiredEvent_EmptyPositions(t *testing.T) { func TestProjectileEventToProjectileMarker(t *testing.T) { // Create a LineStringZM with 3 points (thrown, mid-flight, impact) + // Format: [x, y, z, frameNo] where M = frame number coords := []float64{ - 100.0, 200.0, 10.0, 1000.0, // thrown position - 150.0, 250.0, 15.0, 1001.0, // mid-flight - 200.0, 300.0, 5.0, 1002.0, // impact position + 100.0, 200.0, 10.0, 243.0, // thrown position at frame 243 + 150.0, 250.0, 15.0, 245.0, // mid-flight at frame 245 + 200.0, 300.0, 5.0, 303.0, // impact position at frame 303 } seq := geom.NewSequence(coords, geom.DimXYZM) ls, _ := geom.NewLineString(seq) @@ -766,6 +767,14 @@ func TestProjectileEventToProjectileMarker(t *testing.T) { states[1].Position.X, states[1].Position.Y) } + // States should have correct frame numbers from M coordinate + if states[0].CaptureFrame != 245 { + t.Errorf("expected state[0] CaptureFrame=245, got %d", states[0].CaptureFrame) + } + if states[1].CaptureFrame != 303 { + t.Errorf("expected state[1] CaptureFrame=303, got %d", states[1].CaptureFrame) + } + // States should reference marker ID if states[0].MarkerID != marker.ID { t.Errorf("expected state MarkerID=%d, got %d", marker.ID, states[0].MarkerID) From a8c99eb2aa467e164e089731e25204911cf9e739 Mon Sep 17 00:00:00 2001 From: Florian Kinder Date: Wed, 4 Feb 2026 13:22:35 +0100 Subject: [PATCH 3/3] fix: set proper endFrame for projectile markers - Add EndFrame field to core.Marker - Set endFrame to last position's frame for projectile markers (when grenade explodes/dissipates) instead of -1 (persist forever) - Regular markers default to -1 via builder (0 treated as -1) - Update MarkerToCore to set EndFrame: -1 for regular markers --- internal/model/convert/convert.go | 8 ++++++++ internal/model/convert/convert_test.go | 5 +++++ internal/model/core/marker.go | 1 + internal/storage/memory/export/v1/builder.go | 8 +++++++- 4 files changed, 21 insertions(+), 1 deletion(-) diff --git a/internal/model/convert/convert.go b/internal/model/convert/convert.go index fbb9dfd..2308ac4 100644 --- a/internal/model/convert/convert.go +++ b/internal/model/convert/convert.go @@ -341,6 +341,7 @@ func MarkerToCore(m model.Marker) core.Marker { MissionID: m.MissionID, Time: m.Time, CaptureFrame: m.CaptureFrame, + EndFrame: -1, // Regular markers persist until end MarkerName: m.MarkerName, Direction: m.Direction, MarkerType: m.MarkerType, @@ -461,10 +462,17 @@ func ProjectileEventToProjectileMarker(p model.ProjectileEvent) (core.Marker, [] firstPos = positions[0] } + // EndFrame is the last position's frame (when grenade explodes/dissipates) + endFrame := -1 + if len(frames) > 0 { + endFrame = int(frames[len(frames)-1]) + } + marker := core.Marker{ ID: markerID, MissionID: p.MissionID, CaptureFrame: p.CaptureFrame, + EndFrame: endFrame, MarkerName: markerName, MarkerType: markerType, Text: p.MagazineDisplay, diff --git a/internal/model/convert/convert_test.go b/internal/model/convert/convert_test.go index d22c65d..e5f0fa2 100644 --- a/internal/model/convert/convert_test.go +++ b/internal/model/convert/convert_test.go @@ -748,6 +748,11 @@ func TestProjectileEventToProjectileMarker(t *testing.T) { t.Errorf("expected MarkerName=projectile_100_42, got %s", marker.MarkerName) } + // EndFrame should be the last position's frame (303) + if marker.EndFrame != 303 { + t.Errorf("expected EndFrame=303, got %d", marker.EndFrame) + } + // First position should be in marker if marker.Position.X != 100.0 || marker.Position.Y != 200.0 { t.Errorf("expected marker Position=(100,200), got (%f,%f)", diff --git a/internal/model/core/marker.go b/internal/model/core/marker.go index 4f2343c..46b55c2 100644 --- a/internal/model/core/marker.go +++ b/internal/model/core/marker.go @@ -9,6 +9,7 @@ type Marker struct { MissionID uint Time time.Time CaptureFrame uint + EndFrame int // -1 means persist until end, otherwise frame when marker disappears MarkerName string Direction float32 MarkerType string diff --git a/internal/storage/memory/export/v1/builder.go b/internal/storage/memory/export/v1/builder.go index d0cf812..0ebd0fb 100644 --- a/internal/storage/memory/export/v1/builder.go +++ b/internal/storage/memory/export/v1/builder.go @@ -297,11 +297,17 @@ func Build(data *MissionData) Export { // With "#" prefix, browsers interpret the fragment as an anchor, causing 404s markerColor := strings.TrimPrefix(record.Marker.Color, "#") + // EndFrame: 0 means not set (use -1 to persist), positive means specific end frame + endFrame := record.Marker.EndFrame + if endFrame == 0 { + endFrame = -1 + } + marker := []any{ record.Marker.MarkerType, // [0] type record.Marker.Text, // [1] text record.Marker.CaptureFrame, // [2] startFrame - -1, // [3] endFrame (-1 = persists until end) + endFrame, // [3] endFrame (-1 = persists until end, otherwise frame when marker disappears) record.Marker.OwnerID, // [4] playerId (entity ID of creating player, -1 for system markers) markerColor, // [5] color (# prefix stripped for URL compatibility) sideToIndex(record.Marker.Side), // [6] sideIndex