···4545}
46464747var (
4848- listBinCache = make(map[string]listBinIndex) // revision → index
4848+ listBinCache = make(map[string]listBinIndex) // revision/platform → index
4949 listBinInflight = make(map[string]*listBinLoad)
5050 listBinCacheMu sync.RWMutex
5151- infoCache = make(map[string]map[string]infoAlias) // revision → from-name → duplicate target
5151+ infoCache = make(map[string]map[string]infoAlias) // revision/platform → from-name → duplicate target
5252 infoInflight = make(map[string]*infoLoad)
5353 infoCacheMu sync.RWMutex
5454)
5555+5656+func cacheKey(revision, platform string) string {
5757+ if platform == "" {
5858+ return revision + "/_shared"
5959+ }
6060+ return revision + "/" + platform
6161+}
55625663// infoJSONEntry is one entry from assets/revisions/{rev}/info.json (duplicate files: serve to-name when asked for from-name).
5764type infoJSONEntry struct {
···208215 return idx
209216}
210217211211-func loadListBinIndex(baseDir, revision string) (listBinIndex, bool) {
218218+func loadListBinIndex(baseDir, revision, platform string) (listBinIndex, bool) {
219219+ key := cacheKey(revision, platform)
212220 listBinCacheMu.RLock()
213213- idx, ok := listBinCache[revision]
221221+ idx, ok := listBinCache[key]
214222 listBinCacheMu.RUnlock()
215223 if ok {
216224 return idx, true
217225 }
218226219227 listBinCacheMu.Lock()
220220- if idx, ok := listBinCache[revision]; ok {
228228+ if idx, ok := listBinCache[key]; ok {
221229 listBinCacheMu.Unlock()
222230 return idx, true
223231 }
224224- if load := listBinInflight[revision]; load != nil {
232232+ if load := listBinInflight[key]; load != nil {
225233 listBinCacheMu.Unlock()
226234 <-load.done
227235 return load.idx, load.ok
228236 }
229237 load := &listBinLoad{done: make(chan struct{})}
230230- listBinInflight[revision] = load
238238+ listBinInflight[key] = load
231239 listBinCacheMu.Unlock()
232240233233- filePath := filepath.Join(baseDir, "assets", "revisions", revision, "list.bin")
241241+ filePath := filepath.Join(baseDir, "assets", "revisions", revision, platform, "list.bin")
234242 data, err := os.ReadFile(filePath)
235243 if err != nil {
236244 listBinCacheMu.Lock()
237237- delete(listBinInflight, revision)
245245+ delete(listBinInflight, key)
238246 close(load.done)
239247 listBinCacheMu.Unlock()
240248 return nil, false
···243251 load.idx = idx
244252 load.ok = true
245253 listBinCacheMu.Lock()
246246- listBinCache[revision] = idx
247247- delete(listBinInflight, revision)
254254+ listBinCache[key] = idx
255255+ delete(listBinInflight, key)
248256 close(load.done)
249257 listBinCacheMu.Unlock()
250258 return idx, true
251259}
252260253253-func loadInfoIndex(baseDir, revision string) map[string]infoAlias {
261261+func loadInfoIndex(baseDir, revision, platform string) map[string]infoAlias {
262262+ key := cacheKey(revision, platform)
254263 infoCacheMu.RLock()
255255- m, ok := infoCache[revision]
264264+ m, ok := infoCache[key]
256265 infoCacheMu.RUnlock()
257266 if ok {
258267 return m
259268 }
260269261270 infoCacheMu.Lock()
262262- if m, ok := infoCache[revision]; ok {
271271+ if m, ok := infoCache[key]; ok {
263272 infoCacheMu.Unlock()
264273 return m
265274 }
266266- if load := infoInflight[revision]; load != nil {
275275+ if load := infoInflight[key]; load != nil {
267276 infoCacheMu.Unlock()
268277 <-load.done
269278 return load.m
270279 }
271280 load := &infoLoad{done: make(chan struct{})}
272272- infoInflight[revision] = load
281281+ infoInflight[key] = load
273282 infoCacheMu.Unlock()
274283275275- filePath := filepath.Join(baseDir, "assets", "revisions", revision, "info.json")
284284+ filePath := filepath.Join(baseDir, "assets", "revisions", revision, platform, "info.json")
276285 data, err := os.ReadFile(filePath)
277286 if err != nil {
278287 infoCacheMu.Lock()
279279- infoCache[revision] = nil
280280- delete(infoInflight, revision)
288288+ infoCache[key] = nil
289289+ delete(infoInflight, key)
281290 close(load.done)
282291 infoCacheMu.Unlock()
283292 return nil
···285294 var entries []infoJSONEntry
286295 if err := json.Unmarshal(data, &entries); err != nil {
287296 infoCacheMu.Lock()
288288- infoCache[revision] = nil
289289- delete(infoInflight, revision)
297297+ infoCache[key] = nil
298298+ delete(infoInflight, key)
290299 close(load.done)
291300 infoCacheMu.Unlock()
292301 return nil
···307316 }
308317 load.m = m
309318 infoCacheMu.Lock()
310310- infoCache[revision] = m
311311- delete(infoInflight, revision)
319319+ infoCache[key] = m
320320+ delete(infoInflight, key)
312321 close(load.done)
313322 infoCacheMu.Unlock()
314323 return m
···378387// an en locale fallback is appended (marked IsLocaleFallback so callers can skip MD5 validation).
379388// For paths with non-ASCII characters, mojibake (double-encoded) and fullwidth-to-ASCII
380389// variants are also tried.
381381-func pathStrToFullPaths(baseDir, revision, assetType, pathStr string) []pathCandidate {
390390+func pathStrToFullPaths(baseDir, revision, platform, assetType, pathStr string) []pathCandidate {
382391 fsPath := strings.ReplaceAll(pathStr, ")", "/")
383392 if strings.Contains(fsPath, "..") || filepath.IsAbs(fsPath) || strings.HasPrefix(fsPath, "/") {
384393 return nil
···402411 if strings.Contains(pathStr, ")ko)") {
403412 entries = append(entries, tagged{strings.ReplaceAll(pathStr, ")ko)", ")en)"), true})
404413 }
405405- base := filepath.Join(baseDir, "assets", "revisions", revision)
414414+ base := filepath.Join(baseDir, "assets", "revisions", revision, platform)
406415 var out []pathCandidate
407416 seen := make(map[string]bool)
408417 for _, e := range entries {
···434443 return append(candidates, candidate)
435444}
436445437437-func duplicateCandidatePath(baseDir string, candidate assetCandidate, assetType, targetRevision, targetBaseName string) string {
438438- root := filepath.Join(baseDir, "assets", "revisions", candidate.Revision, assetType)
446446+func duplicateCandidatePath(baseDir string, candidate assetCandidate, platform, assetType, targetRevision, targetBaseName string) string {
447447+ root := filepath.Join(baseDir, "assets", "revisions", candidate.Revision, platform, assetType)
439448 rel, err := filepath.Rel(root, candidate.Path)
440449 if err != nil || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) {
441450 return ""
442451 }
443443- return filepath.Join(baseDir, "assets", "revisions", targetRevision, assetType, filepath.Dir(rel), targetBaseName)
452452+ return filepath.Join(baseDir, "assets", "revisions", targetRevision, platform, assetType, filepath.Dir(rel), targetBaseName)
444453}
445454446455// objectIdToFilePathCandidates returns candidate file paths for the object: list.bin path, locale fallbacks
447456// (ja/ko -> en), then info.json duplicate mappings (from-name -> to-name, possibly in a different revision).
448457// The original locale path is tried first (with MD5 validation); locale fallbacks are tried after
449458// (without MD5 validation, since the hash in list.bin refers to the original locale's content).
459459+//
460460+// Two tiers are searched in order: the requested platform tree (e.g. revisions/0/ios/...) and then
461461+// the un-split shared tree (revisions/0/...) which acts as a fallback for operators who deploy a
462462+// single unified asset dump. Each tier carries its own list.bin md5 so corruption is still detected.
450463// Callers should try each path until one exists on disk.
451451-func objectIdToFilePathCandidates(baseDir, revision, assetType, objectId string) (candidates []assetCandidate, size int64, ok bool) {
452452- idx, ok := loadListBinIndex(baseDir, revision)
453453- if !ok || idx == nil {
454454- return nil, 0, false
455455- }
456456- entry, ok := idx[objectId]
457457- if !ok || entry.Path == "" {
458458- return nil, 0, false
459459- }
460460- paths := pathStrToFullPaths(baseDir, revision, assetType, entry.Path)
461461- if len(paths) == 0 {
462462- return nil, 0, false
463463- }
464464+func objectIdToFilePathCandidates(baseDir, revision, platform, assetType, objectId string) (candidates []assetCandidate, size int64, ok bool) {
464465 seen := make(map[string]bool)
465465- for _, pc := range paths {
466466- md5 := entry.MD5
467467- if pc.IsLocaleFallback {
468468- md5 = ""
466466+ var firstSize int64
467467+ var anyHit bool
468468+469469+ appendForPlatform := func(p, label string) {
470470+ idx, idxOk := loadListBinIndex(baseDir, revision, p)
471471+ if !idxOk || idx == nil {
472472+ return
473473+ }
474474+ entry, entryOk := idx[objectId]
475475+ if !entryOk || entry.Path == "" {
476476+ return
469477 }
470470- candidates = appendUniqueCandidate(candidates, seen, assetCandidate{
471471- Path: pc.Path,
472472- Revision: revision,
473473- Source: "list.bin",
474474- ExpectedMD5: md5,
475475- })
476476- }
477477- infoIndex := loadInfoIndex(baseDir, revision)
478478- if len(infoIndex) > 0 {
479479- for _, c := range candidates {
480480- alias, ok := infoIndex[filepath.Base(c.Path)]
481481- if !ok || alias.ToName == "" {
482482- continue
483483- }
484484- alt := duplicateCandidatePath(baseDir, c, assetType, alias.ToRevision, alias.ToName)
485485- if alt == "" {
486486- continue
478478+ paths := pathStrToFullPaths(baseDir, revision, p, assetType, entry.Path)
479479+ if len(paths) == 0 {
480480+ return
481481+ }
482482+ tierStart := len(candidates)
483483+ for _, pc := range paths {
484484+ md5 := entry.MD5
485485+ if pc.IsLocaleFallback {
486486+ md5 = ""
487487 }
488488 candidates = appendUniqueCandidate(candidates, seen, assetCandidate{
489489- Path: alt,
490490- Revision: alias.ToRevision,
491491- Source: "info.json redirect",
492492- ExpectedMD5: alias.MD5,
489489+ Path: pc.Path,
490490+ Revision: revision,
491491+ Source: "list.bin (" + label + ")",
492492+ ExpectedMD5: md5,
493493 })
494494 }
495495+ infoIndex := loadInfoIndex(baseDir, revision, p)
496496+ if len(infoIndex) > 0 {
497497+ tierCandidates := candidates[tierStart:]
498498+ for _, c := range tierCandidates {
499499+ alias, aliasOk := infoIndex[filepath.Base(c.Path)]
500500+ if !aliasOk || alias.ToName == "" {
501501+ continue
502502+ }
503503+ alt := duplicateCandidatePath(baseDir, c, p, assetType, alias.ToRevision, alias.ToName)
504504+ if alt == "" {
505505+ continue
506506+ }
507507+ candidates = appendUniqueCandidate(candidates, seen, assetCandidate{
508508+ Path: alt,
509509+ Revision: alias.ToRevision,
510510+ Source: "info.json redirect (" + label + ")",
511511+ ExpectedMD5: alias.MD5,
512512+ })
513513+ }
514514+ }
515515+ if !anyHit {
516516+ firstSize = entry.Size
517517+ anyHit = true
518518+ }
495519 }
496496- return candidates, entry.Size, true
520520+521521+ appendForPlatform(platform, platform)
522522+ appendForPlatform("", "shared")
523523+524524+ if !anyHit {
525525+ return nil, 0, false
526526+ }
527527+ return candidates, firstSize, true
497528}
+35-21
server/internal/service/octo.go
···133133 revisions: newRevisionTracker(),
134134 resolver: newAssetResolver(baseDir),
135135 }
136136- s.resolver.Prewarm("0")
136136+ s.resolver.Prewarm("0", platformAndroid)
137137+ s.resolver.Prewarm("0", platformIOS)
138138+ s.resolver.Prewarm("0", "")
137139 s.mux.HandleFunc("/", s.handleAll)
138140 return s
139141}
···142144 return s.mux
143145}
144146147147+// listBinPath prefers the platform-split list.bin and falls back to the un-split shared tree
148148+// when the platform-specific file is missing, so operators with a single unified asset dump
149149+// keep working.
150150+func (s *OctoHTTPServer) listBinPath(revision, platform string) string {
151151+ p := filepath.Join(s.BaseDir, "assets", "revisions", revision, platform, "list.bin")
152152+ if _, err := os.Stat(p); err == nil {
153153+ return p
154154+ }
155155+ return filepath.Join(s.BaseDir, "assets", "revisions", revision, "list.bin")
156156+}
157157+145158func (s *OctoHTTPServer) handleAll(w http.ResponseWriter, r *http.Request) {
146159 path := r.URL.Path
160160+ platform := platformFromUserAgent(r)
147161 isAssetRequest := strings.Contains(path, "/unso-")
148162 isMasterDataRequest := strings.Contains(path, "/assets/release/") && strings.Contains(path, "database.bin")
149163 if !isAssetRequest && !isMasterDataRequest {
150150- log.Printf("[HTTP] %s %s (Host: %s)", r.Method, r.URL.String(), r.Host)
164164+ log.Printf("[HTTP] %s %s (Host: %s, platform: %s)", r.Method, r.URL.String(), r.Host, platform)
151165 for k, v := range r.Header {
152166 log.Printf("[HTTP] %s: %s", k, v)
153167 }
···155169156170 // Octo v2 API — asset bundle management
157171 if strings.HasPrefix(path, "/v2/") {
158158- s.handleOctoV2(w, r, path)
172172+ s.handleOctoV2(w, r, path, platform)
159173 return
160174 }
161175162176 // Octo v1 list: /v1/list/{version}/{revision} — same list.bin as v2, keyed by revision
163177 if strings.HasPrefix(path, "/v1/list/") {
164164- s.serveOctoV1List(w, r, path)
178178+ s.serveOctoV1List(w, r, path, platform)
165179 return
166180 }
167181···188202189203 // Asset bundle requests (from list.bin URLs: .../unso-{v}-{type}/{o}?generation=...&alt=media)
190204 if strings.Contains(path, "/unso-") {
191191- s.serveUnsoAsset(w, r, path)
205205+ s.serveUnsoAsset(w, r, path, platform)
192206 return
193207 }
194208···218232 w.Write([]byte{})
219233}
220234221221-func (s *OctoHTTPServer) handleOctoV2(w http.ResponseWriter, r *http.Request, path string) {
222222- log.Printf("[OctoV2] %s %s", r.Method, path)
235235+func (s *OctoHTTPServer) handleOctoV2(w http.ResponseWriter, r *http.Request, path, platform string) {
236236+ log.Printf("[OctoV2] %s %s (platform=%s)", r.Method, path, platform)
223237224238 // /v2/pub/a/{appId}/v/{version}/list/{offset} — resource listing
225239 if strings.Contains(path, "/list/") {
···228242 requestedRevision := parts[len(parts)-1]
229243 if requestedRevision != "" {
230244 revision := "0"
231231- filePath := filepath.Join(s.BaseDir, "assets", "revisions", "0", "list.bin")
245245+ filePath := s.listBinPath(revision, platform)
232246 if requestedRevision != revision {
233247 log.Printf("[OctoV2] Resource list request revision=%s canonicalized to revision=%s", requestedRevision, revision)
234248 }
235235- log.Printf("[OctoV2] Resource list request — serving %s (requested_revision=%s canonical_revision=%s)", filePath, requestedRevision, revision)
249249+ log.Printf("[OctoV2] Resource list request — serving %s (requested_revision=%s canonical_revision=%s platform=%s)", filePath, requestedRevision, revision, platform)
236250 s.revisions.Remember(r.RemoteAddr, revision)
237237- go s.resolver.Prewarm(revision)
251251+ go s.resolver.Prewarm(revision, platform)
238252 s.serveListBin(w, filePath)
239253 return
240254 }
···259273 w.WriteHeader(200)
260274}
261275262262-// serveOctoV1List handles GET /v1/list/{version}/{revision} — serves assets/revisions/{revision}/list.bin.
263263-func (s *OctoHTTPServer) serveOctoV1List(w http.ResponseWriter, r *http.Request, path string) {
276276+// serveOctoV1List handles GET /v1/list/{version}/{revision} — serves assets/revisions/{revision}/{platform}/list.bin.
277277+func (s *OctoHTTPServer) serveOctoV1List(w http.ResponseWriter, r *http.Request, path, platform string) {
264278 parts := strings.Split(strings.Trim(path, "/"), "/")
265279 // ["v1", "list", "300116832", "0"] -> revision = last segment
266280 requestedRevision := "0"
···268282 requestedRevision = parts[len(parts)-1]
269283 }
270284 revision := "0"
271271- filePath := filepath.Join(s.BaseDir, "assets", "revisions", "0", "list.bin")
285285+ filePath := s.listBinPath(revision, platform)
272286 if requestedRevision != revision {
273287 log.Printf("[OctoV1] list request revision=%s canonicalized to revision=%s", requestedRevision, revision)
274288 }
275275- log.Printf("[OctoV1] %s %s — serving %s (requested_revision=%s canonical_revision=%s)", r.Method, path, filePath, requestedRevision, revision)
289289+ log.Printf("[OctoV1] %s %s — serving %s (requested_revision=%s canonical_revision=%s platform=%s)", r.Method, path, filePath, requestedRevision, revision, platform)
276290 s.revisions.Remember(r.RemoteAddr, revision)
277277- go s.resolver.Prewarm(revision)
291291+ go s.resolver.Prewarm(revision, platform)
278292 s.serveListBin(w, filePath)
279293}
280294281295// serveUnsoAsset serves asset bundle or resource for URLs like /resource-bundle-server/unso-{version}-{type}/{object_id}.
282282-func (s *OctoHTTPServer) serveUnsoAsset(w http.ResponseWriter, r *http.Request, path string) {
296296+func (s *OctoHTTPServer) serveUnsoAsset(w http.ResponseWriter, r *http.Request, path, platform string) {
283297 parts := strings.Split(strings.Trim(path, "/"), "/")
284298 var segment, objectId string
285299 for i, p := range parts {
···311325 return
312326 }
313327 activeRevision := s.revisions.Active(r.RemoteAddr)
314314- resolution, ok := s.resolver.Resolve(objectId, assetType, activeRevision)
328328+ resolution, ok := s.resolver.Resolve(objectId, assetType, activeRevision, platform)
315329 if !ok {
316316- log.Printf("[HTTP] Asset not found: %s (object_id=%s type=%s active_revision=%s) no candidates", path, objectId, assetType, activeRevision)
330330+ log.Printf("[HTTP] Asset not found: %s (object_id=%s type=%s platform=%s active_revision=%s) no candidates", path, objectId, assetType, platform, activeRevision)
317331 w.Header().Set("Content-Type", "application/octet-stream")
318332 w.WriteHeader(http.StatusNotFound)
319333 return
···354368 }
355369 if !strings.EqualFold(actualMD5, candidate.ExpectedMD5) {
356370 md5Mismatches = append(md5Mismatches, candidate.Revision+":"+candidate.Path+" ["+candidate.Source+"] expected="+candidate.ExpectedMD5+" actual="+actualMD5)
357357- log.Printf("[HTTP] Asset md5 mismatch: object_id=%s type=%s path=%s expected=%s actual=%s active_revision=%s list_revision=%s resolved_revision=%s source=%s", objectId, assetType, candidate.Path, candidate.ExpectedMD5, actualMD5, resolution.ActiveRevision, resolution.ListRevision, candidate.Revision, candidate.Source)
371371+ log.Printf("[HTTP] Asset md5 mismatch: object_id=%s type=%s platform=%s path=%s expected=%s actual=%s active_revision=%s list_revision=%s resolved_revision=%s source=%s", objectId, assetType, platform, candidate.Path, candidate.ExpectedMD5, actualMD5, resolution.ActiveRevision, resolution.ListRevision, candidate.Revision, candidate.Source)
358372 f.Close()
359373 continue
360374 }
···366380 return
367381 }
368382 if len(md5Mismatches) > 0 {
369369- log.Printf("[HTTP] Asset md5 mismatches: object_id=%s type=%s active_revision=%s list_revision=%s mismatches=%v", objectId, assetType, resolution.ActiveRevision, resolution.ListRevision, md5Mismatches)
383383+ log.Printf("[HTTP] Asset md5 mismatches: object_id=%s type=%s platform=%s active_revision=%s list_revision=%s mismatches=%v", objectId, assetType, platform, resolution.ActiveRevision, resolution.ListRevision, md5Mismatches)
370384 }
371371- log.Printf("[HTTP] Asset not found: %s (object_id=%s type=%s active_revision=%s list_revision=%s) tried paths: %v", path, objectId, assetType, resolution.ActiveRevision, resolution.ListRevision, triedPaths)
385385+ log.Printf("[HTTP] Asset not found: %s (object_id=%s type=%s platform=%s active_revision=%s list_revision=%s) tried paths: %v", path, objectId, assetType, platform, resolution.ActiveRevision, resolution.ListRevision, triedPaths)
372386 w.Header().Set("Content-Type", "application/octet-stream")
373387 w.WriteHeader(http.StatusNotFound)
374388}
+24
server/internal/service/platform_http.go
···11+package service
22+33+import (
44+ "net/http"
55+ "strings"
66+)
77+88+const (
99+ platformAndroid = "android"
1010+ platformIOS = "ios"
1111+)
1212+1313+// platformFromUserAgent classifies an HTTP request as iOS vs Android based on
1414+// the User-Agent header. Unity's UnityWebRequest does not set a UA on iOS, so
1515+// CFNetwork's default ("<bundle>/<build> CFNetwork/x Darwin/x") is what arrives;
1616+// on Android Unity sets "UnityPlayer/... (UnityWebRequest/...)". Any other UA
1717+// (or none) is treated as Android, matching model.DefaultPlatform.
1818+func platformFromUserAgent(r *http.Request) string {
1919+ ua := r.Header.Get("User-Agent")
2020+ if strings.Contains(ua, "Darwin/") || strings.Contains(ua, "CFNetwork/") {
2121+ return platformIOS
2222+ }
2323+ return platformAndroid
2424+}