// Package empyrionitems parses Empyrion's ItemsConfig.ecf — Eleon's text // config format — into a flat item index keyed by numeric ID. Used by the // panel controller to populate the EAH-style give-item picker, ID-to-name // lookups for inventory views, and category filtering. // // The format is brace-delimited with `Key: Value` properties, comment lines // starting with #, and entries opened by `{ Item Id: N, Name: X` or // `+ Item Id: N, Name: X` (the `+` form extends a prior block; semantically // the same for our purposes). Values may be quoted strings, raw tokens, or // scalar numbers — we keep them as strings and let callers coerce. // // We deliberately ignore nested `{ Child ... }` blocks except for tracking // the brace depth so we don't accidentally close the outer item early. package empyrionitems import ( "bufio" "errors" "io" "os" "strconv" "strings" ) // Item is a single Empyrion item / block / weapon entry. Fields populated // best-effort from properties seen in stock ItemsConfig.ecf; Extras catches // anything we didn't promote to a typed field so callers can still surface // scenario-specific keys (Reforged Eden adds plenty). type Item struct { ID int `json:"id"` Name string `json:"name"` // canonical name token (e.g. "EnergyCellHydrogen") Ref string `json:"ref,omitempty"` // template/parent ref Category string `json:"category,omitempty"` // ingredients, components, food, weapons, ammo, etc Group string `json:"group,omitempty"` // displayed UI grouping Mass string `json:"mass,omitempty"` // kg, raw token Volume string `json:"volume,omitempty"` // L MarketPrice string `json:"marketPrice,omitempty"` StackSize string `json:"stack,omitempty"` Class string `json:"class,omitempty"` // Ranged / Tool / Item / Block HoldType string `json:"holdType,omitempty"` CustomIcon string `json:"customIcon,omitempty"` AllowAt string `json:"allowAt,omitempty"` Description string `json:"description,omitempty"` Extras map[string]string `json:"extras,omitempty"` } // Index is a queryable item store. type Index struct { Items []Item byID map[int]*Item byName map[string]*Item } // LoadFromFile reads ItemsConfig.ecf from disk. func LoadFromFile(path string) (*Index, error) { f, err := os.Open(path) if err != nil { return nil, err } defer f.Close() return LoadFromReader(f) } // LoadFromReaders parses multiple ECF streams into a single merged Index. // Order matters for ID collisions: later readers win, matching Empyrion's // own load order where BlocksConfig is read after ItemsConfig. func LoadFromReaders(readers ...io.Reader) (*Index, error) { merged := &Index{byID: map[int]*Item{}, byName: map[string]*Item{}} for _, r := range readers { idx, err := LoadFromReader(r) if err != nil { return nil, err } for i := range idx.Items { it := idx.Items[i] merged.add(&it) } } return merged, nil } // LoadFromReader parses an ItemsConfig.ecf stream. func LoadFromReader(r io.Reader) (*Index, error) { idx := &Index{byID: map[int]*Item{}, byName: map[string]*Item{}} sc := bufio.NewScanner(r) sc.Buffer(make([]byte, 1024*1024), 1024*1024) var ( current *Item depth int // brace depth WITHIN an item (for child blocks like Child Recipe { ... }) ) for sc.Scan() { line := stripComment(sc.Text()) trim := strings.TrimSpace(line) if trim == "" { continue } // Open a new top-level entry: `{ Item Id: 12, ...`, `{ Block Id: 220, ...`, // `+ Item Id: ...`, `+ Block Id: ...`. Empyrion's ECF format uses // `Item`, `Block`, `Device` interchangeably for the entries we care // about — they're all addressable via the same numeric ID space. if current == nil && isEntryHeader(trim) { it := parseItemHeader(trim) if it != nil { current = it current.Extras = map[string]string{} depth = 1 if strings.HasPrefix(trim, "+") { depth = 0 } } continue } // Inside an item. if current != nil { // Track child brace depth so we don't close the outer item early. opens := strings.Count(trim, "{") closes := strings.Count(trim, "}") depth += opens - closes if depth <= 0 { idx.add(current) current = nil depth = 0 continue } // `+ Item/Block` form has no closing brace; the next entry // header is its terminator. if isEntryHeader(trim) { idx.add(current) current = nil depth = 0 it := parseItemHeader(trim) if it != nil { current = it current.Extras = map[string]string{} depth = 1 if strings.HasPrefix(trim, "+") { depth = 0 } } continue } parseProperty(current, trim) } } if current != nil { idx.add(current) } if err := sc.Err(); err != nil && !errors.Is(err, io.EOF) { return nil, err } return idx, nil } // isEntryHeader returns true for lines that open a top-level Item / Block / // Device entry. Empyrion's ECF format uses several keywords interchangeably: // `{ Item Id: 12, Name: Foo` // `{ Block Id: 220, Name: MetalPieces` // `{ Device Id: 1234, Name: ConstructorT2` // `+ Item Id: 12, Name: Foo` // `+Block Id: 12, ...` // `{ +Block Name: Foo, Ref: Bar` (additive — name-only, no Id) func isEntryHeader(s string) bool { for _, p := range []string{ "{ Item ", "{ Block ", "{ Device ", "+ Item ", "+ Block ", "+ Device ", "{Item ", "{Block ", "{Device ", "+Item ", "+Block ", "+Device ", "{ +Item ", "{ +Block ", "{ +Device ", "{+Item ", "{+Block ", "{+Device ", } { if strings.HasPrefix(s, p) { return true } } return false } // parseItemHeader handles entry headers in any of the forms isEntryHeader // recognizes and returns the partial Item populated from Id/Name/Ref keys. func parseItemHeader(line string) *Item { body := line for _, p := range []string{ "{ +Item ", "{ +Block ", "{ +Device ", "{+Item ", "{+Block ", "{+Device ", "{ Item ", "{ Block ", "{ Device ", "+ Item ", "+ Block ", "+ Device ", "{Item ", "{Block ", "{Device ", "+Item ", "+Block ", "+Device ", } { body = strings.TrimPrefix(body, p) } body = strings.TrimSpace(body) it := &Item{} for _, kv := range splitTopLevel(body, ',') { parts := strings.SplitN(strings.TrimSpace(kv), ":", 2) if len(parts) != 2 { continue } key := strings.TrimSpace(parts[0]) val := stripQuotes(strings.TrimSpace(parts[1])) switch key { case "Id": if id, err := strconv.Atoi(val); err == nil { it.ID = id } case "Name": it.Name = val case "Ref": it.Ref = val } } // Name-only `+Block Name: Foo, Ref: Bar` entries (additive overrides) are // kept only if we can resolve their ID later via the Ref-target. For now // we drop them since they're a small minority and don't affect the picker. if it.Name == "" { return nil } return it } func parseProperty(it *Item, line string) { parts := strings.SplitN(line, ":", 2) if len(parts) != 2 { return } key := strings.TrimSpace(parts[0]) val := stripQuotes(strings.TrimSpace(parts[1])) // Trim trailing comment; key/value can be followed by inline `# ...`. val = stripComment(val) val = strings.TrimSpace(val) switch strings.ToLower(key) { case "category": it.Category = val case "group": it.Group = val case "mass": it.Mass = val case "volume": it.Volume = val case "marketprice": it.MarketPrice = val case "stacksize": it.StackSize = val case "class": it.Class = val case "holdtype": it.HoldType = val case "customicon": it.CustomIcon = val case "allowat": it.AllowAt = val case "description": it.Description = val default: if it.Extras == nil { it.Extras = map[string]string{} } it.Extras[key] = val } } func (idx *Index) add(it *Item) { if it == nil || it.Name == "" { return } // duplicate item IDs use the LAST seen entry (matches Empyrion's own override semantics). if it.ID > 0 { idx.byID[it.ID] = it } idx.byName[strings.ToLower(it.Name)] = it idx.Items = append(idx.Items, *it) } // ByID returns the item with the given numeric ID, or nil. func (idx *Index) ByID(id int) *Item { if it, ok := idx.byID[id]; ok { c := *it return &c } return nil } // ByName looks up an item by its canonical name token (case-insensitive). func (idx *Index) ByName(name string) *Item { if it, ok := idx.byName[strings.ToLower(name)]; ok { c := *it return &c } return nil } // Search returns items whose name OR description contains the query (case- // insensitive). Empty query returns all items. limit <= 0 returns all matches. func (idx *Index) Search(q string, category string, limit int) []Item { q = strings.ToLower(strings.TrimSpace(q)) cat := strings.ToLower(strings.TrimSpace(category)) out := make([]Item, 0, 64) for _, it := range idx.Items { if cat != "" && strings.ToLower(it.Category) != cat { continue } if q != "" { lname := strings.ToLower(it.Name) ldesc := strings.ToLower(it.Description) if !strings.Contains(lname, q) && !strings.Contains(ldesc, q) { continue } } out = append(out, it) if limit > 0 && len(out) >= limit { break } } return out } // Categories returns the distinct, sorted-by-frequency categories present in the index. func (idx *Index) Categories() []string { counts := map[string]int{} for _, it := range idx.Items { if it.Category != "" { counts[it.Category]++ } } out := make([]string, 0, len(counts)) for k := range counts { out = append(out, k) } // stable order: descending by count, then alpha for i := 1; i < len(out); i++ { for j := i; j > 0 && (counts[out[j]] > counts[out[j-1]] || (counts[out[j]] == counts[out[j-1]] && out[j] < out[j-1])); j-- { out[j], out[j-1] = out[j-1], out[j] } } return out } func stripComment(s string) string { // `#` starts a comment unless inside quotes — items config doesn't use `#` // inside string values in stock content, so a naive strip is safe. if i := strings.Index(s, "#"); i >= 0 { return s[:i] } return s } func stripQuotes(s string) string { if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' { return s[1 : len(s)-1] } return s } // splitTopLevel splits on the given separator while ignoring separators // inside braces or quotes. Used to break "Id: 12, Name: Foo" into pieces // without accidentally splitting things like `BuffMod: "a, b, c"`. func splitTopLevel(s string, sep byte) []string { var out []string var cur strings.Builder depth := 0 inQuote := false for i := 0; i < len(s); i++ { c := s[i] switch { case c == '"': inQuote = !inQuote cur.WriteByte(c) case !inQuote && (c == '{' || c == '['): depth++ cur.WriteByte(c) case !inQuote && (c == '}' || c == ']'): depth-- cur.WriteByte(c) case !inQuote && depth == 0 && c == sep: out = append(out, cur.String()) cur.Reset() default: cur.WriteByte(c) } } if cur.Len() > 0 { out = append(out, cur.String()) } return out }