Skip to content

Commit d78e85e

Browse files
committed
Add native ports save sync support
1 parent f03595a commit d78e85e

18 files changed

Lines changed: 599 additions & 4 deletions

backend/cmd/server/models.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,13 @@ type saveSummary struct {
237237
Saturn *saturnDetails `json:"saturn,omitempty"`
238238
Inspection *saveInspection `json:"inspection,omitempty"`
239239
RuntimeProfile string `json:"runtimeProfile,omitempty"`
240+
PortID string `json:"portId,omitempty"`
241+
PortName string `json:"portName,omitempty"`
242+
OriginSystemSlug string `json:"originSystemSlug,omitempty"`
243+
PortSaveKind string `json:"portSaveKind,omitempty"`
244+
RelativePath string `json:"relativePath,omitempty"`
245+
RootRelativePath string `json:"rootRelativePath,omitempty"`
246+
SlotID string `json:"slotId,omitempty"`
240247
CardSlot string `json:"cardSlot,omitempty"`
241248
ProjectionID string `json:"projectionId,omitempty"`
242249
SourceImportID string `json:"sourceImportId,omitempty"`

backend/cmd/server/native_ports.go

Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"path"
6+
"path/filepath"
7+
"strings"
8+
)
9+
10+
const nativePortSystemSlug = "ports"
11+
12+
type nativePortMetadata struct {
13+
PortID string `json:"portId,omitempty"`
14+
PortName string `json:"portName,omitempty"`
15+
OriginSystemSlug string `json:"originSystemSlug,omitempty"`
16+
PortSaveKind string `json:"portSaveKind,omitempty"`
17+
RelativePath string `json:"relativePath,omitempty"`
18+
RootRelativePath string `json:"rootRelativePath,omitempty"`
19+
SlotID string `json:"slotId,omitempty"`
20+
RuntimeProfile string `json:"runtimeProfile,omitempty"`
21+
DisplayTitle string `json:"displayTitle,omitempty"`
22+
}
23+
24+
type nativePortManifest struct {
25+
ID string
26+
Name string
27+
OriginSystemSlug string
28+
RuntimeProfile string
29+
AllowedPatterns []string
30+
}
31+
32+
var nativePortManifests = map[string]nativePortManifest{
33+
"ship-of-harkinian": {
34+
ID: "ship-of-harkinian",
35+
Name: "The Legend of Zelda: Ocarina of Time (Ship of Harkinian)",
36+
OriginSystemSlug: "n64",
37+
RuntimeProfile: "port/ship-of-harkinian",
38+
AllowedPatterns: []string{"Save/global.sav", "Save/file*.sav", "portable_home/Save/global.sav", "portable_home/Save/file*.sav"},
39+
},
40+
"starship": {
41+
ID: "starship",
42+
Name: "Star Fox 64 (Starship)",
43+
OriginSystemSlug: "n64",
44+
RuntimeProfile: "port/starship",
45+
AllowedPatterns: []string{"default.sav", "portable_home/default.sav"},
46+
},
47+
"spaghettikart": {
48+
ID: "spaghettikart",
49+
Name: "Mario Kart 64 (SpaghettiKart)",
50+
OriginSystemSlug: "n64",
51+
RuntimeProfile: "port/spaghettikart",
52+
AllowedPatterns: []string{"default.sav", "portable_home/default.sav"},
53+
},
54+
"super-metroid-native": {
55+
ID: "super-metroid-native",
56+
Name: "Super Metroid (Native Port)",
57+
OriginSystemSlug: "snes",
58+
RuntimeProfile: "port/super-metroid-native",
59+
AllowedPatterns: []string{"saves/*.srm", "portable_home/saves/*.srm"},
60+
},
61+
"sonic1-forever": {
62+
ID: "sonic1-forever",
63+
Name: "Sonic 1 Forever",
64+
OriginSystemSlug: "genesis",
65+
RuntimeProfile: "port/sonic1-forever",
66+
AllowedPatterns: []string{
67+
"Scripts/Save/SaveSel.txt",
68+
"Scripts/Save/SaveSlot.txt",
69+
"portable_home/Scripts/Save/SaveSel.txt",
70+
"portable_home/Scripts/Save/SaveSlot.txt",
71+
},
72+
},
73+
"sonic3-air": {
74+
ID: "sonic3-air",
75+
Name: "Sonic 3 A.I.R.",
76+
OriginSystemSlug: "genesis",
77+
RuntimeProfile: "port/sonic3-air",
78+
AllowedPatterns: []string{
79+
"saves/*.sav",
80+
"saves/*.srm",
81+
"saves/*.bin",
82+
"portable_home/saves/*.sav",
83+
"portable_home/saves/*.srm",
84+
"portable_home/saves/*.bin",
85+
},
86+
},
87+
"opengoal-jak1": {
88+
ID: "opengoal-jak1",
89+
Name: "Jak and Daxter: The Precursor Legacy (OpenGOAL)",
90+
OriginSystemSlug: "ps2",
91+
RuntimeProfile: "port/opengoal-jak1",
92+
},
93+
"opengoal-jak2": {
94+
ID: "opengoal-jak2",
95+
Name: "Jak II (OpenGOAL)",
96+
OriginSystemSlug: "ps2",
97+
RuntimeProfile: "port/opengoal-jak2",
98+
},
99+
}
100+
101+
func nativePortMetadataFromForm(formValue func(string) string, runtimeProfile string) (nativePortMetadata, bool) {
102+
if formValue == nil {
103+
return nativePortMetadata{}, false
104+
}
105+
meta := nativePortMetadata{
106+
PortID: canonicalOptionalSegment(formValue("portId")),
107+
PortName: strings.TrimSpace(formValue("portName")),
108+
OriginSystemSlug: canonicalOptionalSegment(formValue("originSystemSlug")),
109+
PortSaveKind: canonicalOptionalSegment(firstNonEmpty(formValue("portSaveKind"), "progress")),
110+
RelativePath: normalizePortRelativePath(formValue("relativePath")),
111+
RootRelativePath: normalizePortRelativePath(formValue("rootRelativePath")),
112+
SlotID: canonicalOptionalSegment(formValue("slotId")),
113+
RuntimeProfile: strings.TrimSpace(runtimeProfile),
114+
DisplayTitle: strings.TrimSpace(formValue("displayTitle")),
115+
}
116+
return meta, meta.PortID != "" || meta.RuntimeProfile != ""
117+
}
118+
119+
func applyNativePortInput(input saveCreateInput, meta nativePortMetadata) saveCreateInput {
120+
if meta.PortID == "" && meta.RuntimeProfile == "" {
121+
return input
122+
}
123+
if manifest, ok := nativePortManifestForMetadata(meta); ok {
124+
if meta.PortID == "" {
125+
meta.PortID = manifest.ID
126+
}
127+
if meta.PortName == "" {
128+
meta.PortName = manifest.Name
129+
}
130+
if meta.OriginSystemSlug == "" {
131+
meta.OriginSystemSlug = manifest.OriginSystemSlug
132+
}
133+
if meta.RuntimeProfile == "" {
134+
meta.RuntimeProfile = manifest.RuntimeProfile
135+
}
136+
}
137+
input.PortID = meta.PortID
138+
input.PortName = meta.PortName
139+
input.OriginSystemSlug = meta.OriginSystemSlug
140+
input.PortSaveKind = firstNonEmpty(meta.PortSaveKind, "progress")
141+
input.RelativePath = meta.RelativePath
142+
input.RootRelativePath = meta.RootRelativePath
143+
input.SlotID = firstNonEmpty(meta.SlotID, canonicalSegment(strings.TrimSuffix(filepath.Base(meta.RelativePath), filepath.Ext(meta.RelativePath)), "default"))
144+
input.RuntimeProfile = meta.RuntimeProfile
145+
input.SourceArtifactProfile = meta.RuntimeProfile
146+
if input.DisplayTitle == "" {
147+
input.DisplayTitle = firstNonEmpty(meta.DisplayTitle, nativePortDisplayTitle(input))
148+
}
149+
if input.Game.Name == "" || input.Game.Name == strings.TrimSuffix(input.Filename, filepath.Ext(input.Filename)) {
150+
input.Game.Name = input.DisplayTitle
151+
input.Game.DisplayTitle = input.DisplayTitle
152+
}
153+
input.Metadata = mergeRSMMetadata(input.Metadata, "nativePort", nativePortMetadataMap(input))
154+
return input
155+
}
156+
157+
func nativePortMetadataMap(input saveCreateInput) map[string]any {
158+
return map[string]any{
159+
"portId": strings.TrimSpace(input.PortID),
160+
"portName": strings.TrimSpace(input.PortName),
161+
"originSystemSlug": strings.TrimSpace(input.OriginSystemSlug),
162+
"portSaveKind": firstNonEmpty(strings.TrimSpace(input.PortSaveKind), "progress"),
163+
"relativePath": strings.TrimSpace(input.RelativePath),
164+
"rootRelativePath": strings.TrimSpace(input.RootRelativePath),
165+
"slotId": strings.TrimSpace(input.SlotID),
166+
"runtimeProfile": strings.TrimSpace(input.RuntimeProfile),
167+
"displayTitle": strings.TrimSpace(input.DisplayTitle),
168+
}
169+
}
170+
171+
func nativePortDisplayTitle(input saveCreateInput) string {
172+
portName := strings.TrimSpace(input.PortName)
173+
if portName == "" {
174+
portName = strings.TrimSpace(input.PortID)
175+
}
176+
if portName == "" {
177+
portName = "Native Port"
178+
}
179+
slot := strings.TrimSpace(input.SlotID)
180+
if slot == "" {
181+
slot = strings.TrimSuffix(filepath.Base(input.RelativePath), filepath.Ext(input.RelativePath))
182+
}
183+
if slot == "" {
184+
return portName
185+
}
186+
return portName + " - " + nativePortSlotLabel(slot)
187+
}
188+
189+
func nativePortSlotLabel(slot string) string {
190+
clean := strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(slot, "_", " "), "-", " "))
191+
switch strings.ToLower(strings.ReplaceAll(clean, " ", "")) {
192+
case "global":
193+
return "Global"
194+
case "default":
195+
return "Default"
196+
case "savesel":
197+
return "Save Select"
198+
case "saveslot":
199+
return "Save Slot"
200+
}
201+
if strings.HasPrefix(strings.ToLower(clean), "file") && len(clean) > 4 {
202+
return "File " + strings.TrimSpace(clean[4:])
203+
}
204+
return titleizeNativePortSlot(clean)
205+
}
206+
207+
func titleizeNativePortSlot(value string) string {
208+
words := strings.Fields(value)
209+
if len(words) == 0 {
210+
return "Default"
211+
}
212+
for index, word := range words {
213+
if word == "" {
214+
continue
215+
}
216+
words[index] = strings.ToUpper(word[:1]) + word[1:]
217+
}
218+
return strings.Join(words, " ")
219+
}
220+
221+
func validateNativePortSave(input saveCreateInput, detection saveSystemDetectionResult) consoleValidationResult {
222+
if !detection.Evidence.HelperTrusted && !metadataHasTrustedSystemEvidence(input.Metadata) {
223+
return consoleValidationResult{
224+
Rejected: true,
225+
RejectReason: "native port saves must come from a trusted helper manifest",
226+
}
227+
}
228+
meta := nativePortMetadataFromInput(input)
229+
manifest, ok := nativePortManifestForMetadata(meta)
230+
if !ok {
231+
return consoleValidationResult{Rejected: true, RejectReason: "unknown native port manifest"}
232+
}
233+
if canonicalOptionalSegment(meta.PortSaveKind) != "progress" {
234+
return consoleValidationResult{Rejected: true, RejectReason: "only native port progress saves are supported"}
235+
}
236+
if len(input.Payload) == 0 {
237+
return consoleValidationResult{Rejected: true, RejectReason: "native port save payload is empty"}
238+
}
239+
if len(input.Payload) > 16*1024*1024 {
240+
return consoleValidationResult{Rejected: true, RejectReason: "native port save payload is too large"}
241+
}
242+
if !nativePortPathAllowed(manifest, meta.RelativePath) {
243+
return consoleValidationResult{
244+
Rejected: true,
245+
RejectReason: fmt.Sprintf("native port path %q is not allowed for %s", meta.RelativePath, manifest.ID),
246+
}
247+
}
248+
if meta.RootRelativePath != "" && !safePortRelativePath(meta.RootRelativePath) {
249+
return consoleValidationResult{Rejected: true, RejectReason: "native port root-relative path is unsafe"}
250+
}
251+
return consoleValidationResult{
252+
Inspection: &saveInspection{
253+
ParserLevel: saveParserLevelContainer,
254+
ParserID: "native-port-manifest",
255+
ValidatedSystem: nativePortSystemSlug,
256+
ValidatedGameID: manifest.ID,
257+
ValidatedGameTitle: nativePortDisplayTitle(input),
258+
TrustLevel: validationTrustLevel(saveParserLevelContainer),
259+
Evidence: []string{
260+
"trusted helper native port manifest",
261+
"portId=" + manifest.ID,
262+
"relativePath=" + meta.RelativePath,
263+
},
264+
PayloadSizeBytes: len(input.Payload),
265+
SemanticFields: map[string]any{
266+
"originSystemSlug": manifest.OriginSystemSlug,
267+
"portSaveKind": meta.PortSaveKind,
268+
"slotId": meta.SlotID,
269+
"runtimeProfile": manifest.RuntimeProfile,
270+
},
271+
},
272+
}
273+
}
274+
275+
func nativePortMetadataFromInput(input saveCreateInput) nativePortMetadata {
276+
return nativePortMetadata{
277+
PortID: strings.TrimSpace(input.PortID),
278+
PortName: strings.TrimSpace(input.PortName),
279+
OriginSystemSlug: canonicalOptionalSegment(input.OriginSystemSlug),
280+
PortSaveKind: canonicalOptionalSegment(firstNonEmpty(input.PortSaveKind, "progress")),
281+
RelativePath: normalizePortRelativePath(input.RelativePath),
282+
RootRelativePath: normalizePortRelativePath(input.RootRelativePath),
283+
SlotID: canonicalOptionalSegment(input.SlotID),
284+
RuntimeProfile: strings.TrimSpace(input.RuntimeProfile),
285+
DisplayTitle: strings.TrimSpace(input.DisplayTitle),
286+
}
287+
}
288+
289+
func nativePortManifestForMetadata(meta nativePortMetadata) (nativePortManifest, bool) {
290+
if meta.PortID != "" {
291+
manifest, ok := nativePortManifests[meta.PortID]
292+
return manifest, ok
293+
}
294+
profile := strings.TrimSpace(meta.RuntimeProfile)
295+
for _, manifest := range nativePortManifests {
296+
if profile != "" && strings.EqualFold(manifest.RuntimeProfile, profile) {
297+
return manifest, true
298+
}
299+
}
300+
return nativePortManifest{}, false
301+
}
302+
303+
func nativePortPathAllowed(manifest nativePortManifest, relativePath string) bool {
304+
relativePath = normalizePortRelativePath(relativePath)
305+
if !safePortRelativePath(relativePath) {
306+
return false
307+
}
308+
for _, pattern := range manifest.AllowedPatterns {
309+
if ok, _ := path.Match(pattern, relativePath); ok {
310+
return true
311+
}
312+
}
313+
return false
314+
}
315+
316+
func normalizePortRelativePath(raw string) string {
317+
value := strings.TrimSpace(strings.ReplaceAll(raw, "\\", "/"))
318+
value = path.Clean(value)
319+
if value == "." {
320+
return ""
321+
}
322+
return strings.TrimPrefix(value, "./")
323+
}
324+
325+
func safePortRelativePath(value string) bool {
326+
value = normalizePortRelativePath(value)
327+
return value != "" && !strings.HasPrefix(value, "../") && !strings.Contains(value, "/../") && !path.IsAbs(value)
328+
}

0 commit comments

Comments
 (0)