docs: add FLE bridge direction pivot and squad-injection test tooling
Adds the app-centric FLE bridge direction (direction.md) replacing the Blaze-backend route as primary plan, plus a corrected foundational test procedure and snapshot/diff/apply tooling for reverse-engineering FLE's Freeze Lineup write mechanism. Also picks up prior untracked research docs (status-review, fut-integration-options, fifa23-startup-flow, track-c) and existing capture/export tooling that hadn't been committed yet.
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
-- squad-injector/apply_lineup_write.lua
|
||||
-- FIFA Live Editor Lua script (v2 API).
|
||||
--
|
||||
-- TEMPLATE — fill in TARGET_TABLE / TARGET_FIELDS below using whatever
|
||||
-- diff_snapshots.py reports after the snapshot/Freeze-Lineup/snapshot cycle
|
||||
-- described in docs/foundational-xi-injection-test.md. This script cannot
|
||||
-- run correctly until those are filled in; it's the second half of the
|
||||
-- foundational test, not a standalone tool.
|
||||
--
|
||||
-- Once filled in, this replicates the discovered write programmatically
|
||||
-- (no GUI/Formation Editor needed), which is what build-order step 2 (the
|
||||
-- app->game bridge) needs to drive a squad push from outside the game.
|
||||
--
|
||||
-- Correct EditDBTableField usage (confirmed from FLE's own Lua API docs and
|
||||
-- lua/scripts/99ovr_99pot.lua): mutate the cell's .value in place, then pass
|
||||
-- THE CELL ITSELF (not table/index/field-name/value as separate args) to
|
||||
-- EditDBTableField:
|
||||
--
|
||||
-- local rows = GetDBTableRows("players")
|
||||
-- local row = rows[1]
|
||||
-- row["overallrating"].value = "99"
|
||||
-- local ok = EditDBTableField(row["overallrating"])
|
||||
|
||||
local IN_PATH = "C:\\FIFA 23 Live Editor\\openfut_test_xi.json"
|
||||
local OUT_PATH = "C:\\FIFA 23 Live Editor\\openfut_apply_result.json"
|
||||
|
||||
-- ── FILL IN AFTER RUNNING diff_snapshots.py ──────────────────────────────────
|
||||
local TARGET_TABLE = nil -- e.g. "teamsheet" — set this once the diff identifies it
|
||||
local TARGET_FIELDS = {
|
||||
-- e.g. { name = "selected", value = function(entry) return "1" end },
|
||||
-- e.g. { name = "position", value = function(entry) return entry.position end },
|
||||
}
|
||||
|
||||
local function json_val(v)
|
||||
local t = type(v)
|
||||
if t == "nil" then return "null"
|
||||
elseif t == "boolean" then return tostring(v)
|
||||
elseif t == "number" then return string.format("%.6g", v)
|
||||
elseif t == "string" then
|
||||
return '"' .. v:gsub('\\', '\\\\'):gsub('"', '\\"'):gsub('\n', '\\n') .. '"'
|
||||
elseif t == "table" then
|
||||
local n = #v
|
||||
if n > 0 then
|
||||
local parts = {}
|
||||
for i = 1, n do parts[i] = json_val(v[i]) end
|
||||
return "[" .. table.concat(parts, ",") .. "]"
|
||||
else
|
||||
local parts = {}
|
||||
for k, vv in pairs(v) do
|
||||
parts[#parts + 1] = json_val(tostring(k)) .. ":" .. json_val(vv)
|
||||
end
|
||||
return "{" .. table.concat(parts, ",") .. "}"
|
||||
end
|
||||
end
|
||||
return "null"
|
||||
end
|
||||
|
||||
local function parse_json(s)
|
||||
local i = 1
|
||||
local function skip_ws() while s:sub(i, i):match("%s") do i = i + 1 end end
|
||||
local parse_value
|
||||
|
||||
local function parse_string()
|
||||
i = i + 1
|
||||
local start = i
|
||||
local out = {}
|
||||
while s:sub(i, i) ~= '"' do
|
||||
if s:sub(i, i) == "\\" then
|
||||
out[#out + 1] = s:sub(start, i - 1)
|
||||
local esc = s:sub(i + 1, i + 1)
|
||||
out[#out + 1] = ({ n = "\n", t = "\t", ['"'] = '"', ["\\"] = "\\" })[esc] or esc
|
||||
i = i + 2
|
||||
start = i
|
||||
else
|
||||
i = i + 1
|
||||
end
|
||||
end
|
||||
out[#out + 1] = s:sub(start, i - 1)
|
||||
i = i + 1
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
local function parse_number()
|
||||
local start = i
|
||||
while s:sub(i, i):match("[%-%+%.eE0-9]") do i = i + 1 end
|
||||
return tonumber(s:sub(start, i - 1))
|
||||
end
|
||||
|
||||
local function parse_array()
|
||||
i = i + 1
|
||||
local out = {}
|
||||
skip_ws()
|
||||
if s:sub(i, i) == "]" then i = i + 1; return out end
|
||||
while true do
|
||||
skip_ws()
|
||||
out[#out + 1] = parse_value()
|
||||
skip_ws()
|
||||
if s:sub(i, i) == "," then i = i + 1 else break end
|
||||
end
|
||||
skip_ws()
|
||||
i = i + 1
|
||||
return out
|
||||
end
|
||||
|
||||
local function parse_object()
|
||||
i = i + 1
|
||||
local out = {}
|
||||
skip_ws()
|
||||
if s:sub(i, i) == "}" then i = i + 1; return out end
|
||||
while true do
|
||||
skip_ws()
|
||||
local key = parse_string()
|
||||
skip_ws()
|
||||
i = i + 1
|
||||
skip_ws()
|
||||
out[key] = parse_value()
|
||||
skip_ws()
|
||||
if s:sub(i, i) == "," then i = i + 1 else break end
|
||||
end
|
||||
skip_ws()
|
||||
i = i + 1
|
||||
return out
|
||||
end
|
||||
|
||||
parse_value = function()
|
||||
skip_ws()
|
||||
local c = s:sub(i, i)
|
||||
if c == '"' then return parse_string()
|
||||
elseif c == "{" then return parse_object()
|
||||
elseif c == "[" then return parse_array()
|
||||
elseif c == "t" then i = i + 4; return true
|
||||
elseif c == "f" then i = i + 5; return false
|
||||
elseif c == "n" then i = i + 4; return nil
|
||||
else return parse_number()
|
||||
end
|
||||
end
|
||||
|
||||
skip_ws()
|
||||
return parse_value()
|
||||
end
|
||||
|
||||
local function read_file(path)
|
||||
local f = io.open(path, "r")
|
||||
if not f then return nil end
|
||||
local content = f:read("*a")
|
||||
f:close()
|
||||
return content
|
||||
end
|
||||
|
||||
local function write_file(path, content)
|
||||
local f = io.open(path, "w")
|
||||
if not f then return false end
|
||||
f:write(content)
|
||||
f:close()
|
||||
return true
|
||||
end
|
||||
|
||||
if not TARGET_TABLE or #TARGET_FIELDS == 0 then
|
||||
MessageBox("OpenFUT Apply", "TARGET_TABLE / TARGET_FIELDS are not filled in yet. " ..
|
||||
"Run the snapshot/Freeze-Lineup/snapshot/diff cycle first (see docs/foundational-xi-injection-test.md).")
|
||||
return
|
||||
end
|
||||
|
||||
local raw = read_file(IN_PATH)
|
||||
if not raw then
|
||||
MessageBox("OpenFUT Apply", "ERROR: could not read " .. IN_PATH)
|
||||
return
|
||||
end
|
||||
|
||||
local ok_parse, request = pcall(parse_json, raw)
|
||||
if not ok_parse or not request or not request.xi then
|
||||
MessageBox("OpenFUT Apply", "ERROR: could not parse " .. IN_PATH)
|
||||
return
|
||||
end
|
||||
|
||||
local rows = GetDBTableRows(TARGET_TABLE) or {}
|
||||
local result = {
|
||||
attempted_at = os.date("!%Y-%m-%dT%H:%M:%SZ"),
|
||||
is_career_mode = IsInCM(),
|
||||
target_table = TARGET_TABLE,
|
||||
rows_written = {},
|
||||
errors = {},
|
||||
}
|
||||
|
||||
for _, entry in ipairs(request.xi) do
|
||||
local pid = entry.player_id
|
||||
local matched_row = nil
|
||||
for _, row in ipairs(rows) do
|
||||
local cell = row["playerid"] or row["player_id"]
|
||||
if cell and math.floor(cell.value) == math.floor(pid) then
|
||||
matched_row = row
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if not matched_row then
|
||||
result.errors[#result.errors + 1] = "player_id " .. tostring(pid) .. " not found in " .. TARGET_TABLE
|
||||
else
|
||||
local row_result = { player_id = pid, writes = {} }
|
||||
for _, fdef in ipairs(TARGET_FIELDS) do
|
||||
local cell = matched_row[fdef.name]
|
||||
if cell then
|
||||
cell.value = fdef.value(entry)
|
||||
local ok = EditDBTableField(cell)
|
||||
row_result.writes[#row_result.writes + 1] = { field = fdef.name, value = cell.value, ok = ok }
|
||||
else
|
||||
row_result.writes[#row_result.writes + 1] = { field = fdef.name, ok = false, err = "field not found on row" }
|
||||
end
|
||||
end
|
||||
result.rows_written[#result.rows_written + 1] = row_result
|
||||
end
|
||||
end
|
||||
|
||||
write_file(OUT_PATH, json_val(result))
|
||||
Log("[apply] done, see " .. OUT_PATH)
|
||||
MessageBox("OpenFUT Apply", "Rows written: " .. #result.rows_written .. " / " .. #request.xi ..
|
||||
"\nErrors: " .. #result.errors .. "\n\n" .. OUT_PATH)
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Diff two openfut_snapshot_*.json files from snapshot_lineup_tables.lua.
|
||||
|
||||
Usage: diff_snapshots.py before.json after.json
|
||||
|
||||
Reports, per table, which rows changed and which fields differed —
|
||||
this is how the real DB write behind FLE's "Freeze Lineup" GUI feature
|
||||
gets discovered (see docs/foundational-xi-injection-test.md).
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
|
||||
|
||||
def row_key(row):
|
||||
for candidate in ("playerid", "player_id", "id", "rowid"):
|
||||
if candidate in row:
|
||||
return row[candidate]
|
||||
return json.dumps(row, sort_keys=True)
|
||||
|
||||
|
||||
def diff_table(name, before, after):
|
||||
before_rows = {row_key(r): r for r in before.get("rows", [])}
|
||||
after_rows = {row_key(r): r for r in after.get("rows", [])}
|
||||
|
||||
changes = []
|
||||
for key, after_row in after_rows.items():
|
||||
before_row = before_rows.get(key)
|
||||
if before_row is None:
|
||||
changes.append({"row": key, "type": "added", "row_data": after_row})
|
||||
continue
|
||||
field_diffs = {}
|
||||
for field in set(before_row) | set(after_row):
|
||||
bval = before_row.get(field)
|
||||
aval = after_row.get(field)
|
||||
if bval != aval:
|
||||
field_diffs[field] = {"before": bval, "after": aval}
|
||||
if field_diffs:
|
||||
changes.append({"row": key, "type": "modified", "fields": field_diffs})
|
||||
|
||||
for key in before_rows:
|
||||
if key not in after_rows:
|
||||
changes.append({"row": key, "type": "removed"})
|
||||
|
||||
if changes:
|
||||
print(f"\n=== {name}: {len(changes)} changed row(s) ===")
|
||||
for c in changes:
|
||||
print(json.dumps(c, indent=2))
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 3:
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
|
||||
with open(sys.argv[1]) as f:
|
||||
before = json.load(f)
|
||||
with open(sys.argv[2]) as f:
|
||||
after = json.load(f)
|
||||
|
||||
table_names = set(before.get("tables", {})) | set(after.get("tables", {}))
|
||||
any_changes = False
|
||||
for name in sorted(table_names):
|
||||
b = before.get("tables", {}).get(name, {"rows": []})
|
||||
a = after.get("tables", {}).get(name, {"rows": []})
|
||||
before_len = len(b.get("rows", []))
|
||||
after_len = len(a.get("rows", []))
|
||||
if before_len != after_len:
|
||||
print(f"{name}: row count changed {before_len} -> {after_len}")
|
||||
any_changes = True
|
||||
diff_table(name, b, a)
|
||||
|
||||
if not any_changes:
|
||||
print("(row-count-only check found no differences; per-field diffs above are authoritative)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,111 @@
|
||||
-- squad-injector/snapshot_lineup_tables.lua
|
||||
-- FIFA Live Editor Lua script (v2 API).
|
||||
--
|
||||
-- Dumps every DB table whose name looks lineup/formation/squad-related,
|
||||
-- full rows + fields, to a timestamped JSON file. Run it once BEFORE using
|
||||
-- FLE's GUI "Freeze Lineup" feature (Formation Editor -> arrange players on
|
||||
-- pitch -> tick "Freeze Lineup" -> Data -> Save) and once AFTER, then diff
|
||||
-- the two snapshots with diff_snapshots.py to find which table/field(s) the
|
||||
-- GUI feature actually writes.
|
||||
--
|
||||
-- This exists because "Freeze Lineup" is a confirmed, documented mechanism
|
||||
-- for forcing a starting XI (FLE wiki: Formation-Editor.md) but it's GUI-only
|
||||
-- — its underlying DB write is not documented anywhere. Diffing before/after
|
||||
-- snapshots is how we find it, the same way export_squad.lua's exhaustive
|
||||
-- table dump was the oracle for Track C.
|
||||
--
|
||||
-- Output: C:\FIFA 23 Live Editor\openfut_snapshot_<unix-ish timestamp>.json
|
||||
|
||||
local OUT_DIR = "C:\\FIFA 23 Live Editor\\"
|
||||
|
||||
-- Keyword filter, broader than squad-exporter's FUT-table filter — Freeze
|
||||
-- Lineup is a formation/tactics feature so the write could land in any of
|
||||
-- these families. "players" is included because position fields on the
|
||||
-- player row itself may also change.
|
||||
local KEYWORDS = { "squad", "lineup", "formation", "tactic", "teamsheet", "selection", "players", "teams" }
|
||||
|
||||
local function json_val(v)
|
||||
local t = type(v)
|
||||
if t == "nil" then return "null"
|
||||
elseif t == "boolean" then return tostring(v)
|
||||
elseif t == "number" then
|
||||
if v ~= v then return "null" end
|
||||
return string.format("%.6g", v)
|
||||
elseif t == "string" then
|
||||
return '"' .. v:gsub('\\', '\\\\'):gsub('"', '\\"'):gsub('\n', '\\n') .. '"'
|
||||
elseif t == "table" then
|
||||
local n = #v
|
||||
if n > 0 then
|
||||
local parts = {}
|
||||
for i = 1, n do parts[i] = json_val(v[i]) end
|
||||
return "[" .. table.concat(parts, ",") .. "]"
|
||||
else
|
||||
local parts = {}
|
||||
for k, vv in pairs(v) do
|
||||
parts[#parts + 1] = json_val(tostring(k)) .. ":" .. json_val(vv)
|
||||
end
|
||||
return "{" .. table.concat(parts, ",") .. "}"
|
||||
end
|
||||
end
|
||||
return "null"
|
||||
end
|
||||
|
||||
local function export_table(tbl_name)
|
||||
local fields = GetDBTableFields(tbl_name) or {}
|
||||
local field_names = {}
|
||||
for _, f in ipairs(fields) do field_names[#field_names + 1] = f.name end
|
||||
|
||||
local rows = GetDBTableRows(tbl_name)
|
||||
if not rows or #rows == 0 then
|
||||
Log("[snapshot] table '" .. tbl_name .. "': empty or not found")
|
||||
return { fields = field_names, rows = {} }
|
||||
end
|
||||
|
||||
local out_rows = {}
|
||||
for _, row in ipairs(rows) do
|
||||
local rec = {}
|
||||
for _, fname in ipairs(field_names) do
|
||||
local cell = row[fname]
|
||||
if cell then rec[fname] = cell.value end
|
||||
end
|
||||
out_rows[#out_rows + 1] = rec
|
||||
end
|
||||
Log("[snapshot] table '" .. tbl_name .. "': " .. #out_rows .. " rows, " .. #field_names .. " fields")
|
||||
return { fields = field_names, rows = out_rows }
|
||||
end
|
||||
|
||||
local all_table_names = GetDBTablesNames()
|
||||
local matched = {}
|
||||
for _, tname in ipairs(all_table_names) do
|
||||
local lower = tname:lower()
|
||||
for _, kw in ipairs(KEYWORDS) do
|
||||
if lower:find(kw) then
|
||||
matched[#matched + 1] = tname
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local snapshot = {
|
||||
captured_at = os.date("!%Y-%m-%dT%H:%M:%SZ"),
|
||||
is_career_mode = IsInCM(),
|
||||
matched_table_names = matched,
|
||||
tables = {},
|
||||
}
|
||||
|
||||
for _, tname in ipairs(matched) do
|
||||
snapshot.tables[tname] = export_table(tname)
|
||||
end
|
||||
|
||||
local out_path = OUT_DIR .. "openfut_snapshot_" .. os.date("!%Y%m%dT%H%M%SZ") .. ".json"
|
||||
local f = io.open(out_path, "w")
|
||||
if f then
|
||||
f:write(json_val(snapshot))
|
||||
f:close()
|
||||
Log("[snapshot] written to " .. out_path)
|
||||
MessageBox("OpenFUT Snapshot", "Snapshotted " .. #matched .. " tables.\n" .. out_path ..
|
||||
"\n\nTake one snapshot BEFORE Freeze Lineup, one AFTER, then run diff_snapshots.py on the two files.")
|
||||
else
|
||||
Log("[snapshot] ERROR: could not open " .. out_path)
|
||||
MessageBox("OpenFUT Snapshot", "ERROR writing to " .. out_path)
|
||||
end
|
||||
Reference in New Issue
Block a user