1package gen23import (4 "bytes"5 "context"6 "crypto/sha256"7 "encoding/hex"8 "encoding/json"9 "errors"10 "fmt"11 "os"12 "os/exec"13 "runtime"14 "sort"15 "strings"16 "sync"17 "syscall"18 "time"1920 "mosaic-engine/pack"21 "mosaic-engine/rules"22)2324// A field is a large board generated as a grid of chunks. Every chunk is25// an ordinary scatter generation over its own region of the field; the26// regions of neighbouring chunks overlap by a band so tiles can straddle27// the border, and a chunk generated later sees the earlier neighbours'28// tiles in that band as fixed context (prefilled, immutable, pin-eligible29// like any solved neighbour). Chunks live on an integer lattice: chunk30// (cx, cy) covers the region starting at origin + (cx, cy) * pitch, with31// pitch = chunk_g - band. They are coloured by (cx mod 2, cy mod 2) and32// generated colour by colour, so that chunks of one colour never share a33// cell and run in parallel, and every chunk of a later colour is unique34// given its earlier neighbours. The whole field is therefore unique, and35// the output does not depend on how many chunks ran at once.36//37// A field grows: GrowField generates the ring of lattice cells around the38// occupied set, against the existing tiles as context, and emits a delta39// (the new tiles only, the merged solution). Cell ids are y*G+x on the40// full field, which is allocated once (G) and never changes, so growth41// never renumbers anything: new tile ids continue from the old ones.4243// FieldParams plans a field of Chunks x Chunks regions of ChunkG cells,44// overlapping by Band cells, on a field of G cells per side (default45// Chunks*ChunkG - (Chunks-1)*Band; a larger G centres the initial grid and46// leaves room to grow).47type FieldParams struct {48 Seed string `json:"seed"`49 G int `json:"G,omitempty"`50 Chunks int `json:"chunks"`51 ChunkG int `json:"chunk_g"`52 Band int `json:"band"`53 Kind string `json:"kind"`54 Coverage float64 `json:"coverage,omitempty"`55 Compound float64 `json:"compound,omitempty"`56 Jobs int `json:"jobs,omitempty"` // chunks generated at once (default 4)57 Retries int `json:"retries,omitempty"` // re-packs of a chunk whose generation fails (default 3)58 Timeout time.Duration59}6061// FieldPlan is the geometry of a field's chunk lattice.62type FieldPlan struct {63 G int64 Chunks int65 ChunkG int66 Band int67 Origin [2]int // cells: chunk (0,0) starts here68 // the initial grid, in lattice coordinates 0..Chunks-169 Regions [][4]int // per chunk index cy*Chunks+cx70 Colour []int // (cx mod 2) + 2*(cy mod 2)71}7273func (p FieldPlan) pitch() int { return p.ChunkG - p.Band }7475// regionOf is the region of lattice chunk (cx, cy), clipped to the field;76// ok is false when less than pack.MaxN cells remain on either side.77func (p FieldPlan) regionOf(cx, cy int) (r [4]int, ok bool) {78 x0 := p.Origin[0] + cx*p.pitch()79 y0 := p.Origin[1] + cy*p.pitch()80 x1, y1 := x0+p.ChunkG, y0+p.ChunkG81 x0, y0 = max(x0, 0), max(y0, 0)82 x1, y1 = min(x1, p.G), min(y1, p.G)83 // a clipped region must keep at least MaxN cells beyond the band it84 // shares with a neighbour, or there is nothing to place in it85 if x1-x0 < p.Band+pack.MaxN || y1-y0 < p.Band+pack.MaxN {86 return r, false87 }88 return [4]int{x0, y0, x1 - x0, y1 - y0}, true89}9091func colourOf(cx, cy int) int {92 return ((cx%2)+2)%2 + 2*(((cy%2)+2)%2)93}9495// PlanField lays out the initial chunk grid.96func PlanField(p FieldParams) (FieldPlan, error) {97 if p.Chunks < 1 || p.ChunkG < pack.MaxN {98 return FieldPlan{}, fmt.Errorf("field needs chunks >= 1 and chunk_g >= %d", pack.MaxN)99 }100 if p.Band <= 0 {101 p.Band = 16102 }103 if 2*p.Band > p.ChunkG {104 return FieldPlan{}, fmt.Errorf("band %d too wide for chunk_g %d", p.Band, p.ChunkG)105 }106 pitch := p.ChunkG - p.Band107 def := p.Chunks*pitch + p.Band108 G := p.G109 if G == 0 {110 G = def111 }112 if G < def {113 return FieldPlan{}, fmt.Errorf("G=%d smaller than the %d the chunk grid needs", G, def)114 }115 plan := FieldPlan{G: G, Chunks: p.Chunks, ChunkG: p.ChunkG, Band: p.Band}116 plan.Origin = [2]int{(G - def) / 2, (G - def) / 2}117 for cy := 0; cy < p.Chunks; cy++ {118 for cx := 0; cx < p.Chunks; cx++ {119 r, ok := plan.regionOf(cx, cy)120 if !ok {121 return FieldPlan{}, fmt.Errorf("chunk (%d,%d) does not fit", cx, cy)122 }123 plan.Regions = append(plan.Regions, r)124 plan.Colour = append(plan.Colour, colourOf(cx, cy))125 }126 }127 return plan, nil128}129130// planOf rebuilds the lattice plan from a field's public record.131func planOf(m *Manifest) (FieldPlan, error) {132 f := m.Field133 if f == nil {134 return FieldPlan{}, errors.New("not a field: manifest has no field record")135 }136 return FieldPlan{G: m.G, Chunks: f.Chunks, ChunkG: f.ChunkG, Band: f.Band, Origin: f.Origin}, nil137}138139// Runner generates one chunk (or any board) from its params.140type Runner func(ctx context.Context, p Params) (*Output, error)141142// InProcess runs a chunk in this process.143func InProcess(ctx context.Context, p Params) (*Output, error) {144 return Generate(ctx, p)145}146147// SubprocessRunner runs `exe gen` with the params as JSON on stdin.148func SubprocessRunner(exe string, timeout time.Duration) Runner {149 return func(ctx context.Context, p Params) (*Output, error) {150 in, err := json.Marshal(p)151 if err != nil {152 return nil, err153 }154 args := []string{"gen"}155 if timeout > 0 {156 args = append(args, "--timeout", timeout.String())157 }158 cmd := exec.CommandContext(ctx, exe, args...)159 cmd.Stdin = bytes.NewReader(in)160 var out, errb bytes.Buffer161 cmd.Stdout = &out162 cmd.Stderr = &errb163 cmd.Env = os.Environ()164 if Verbose && os.Getenv("MOSAIC_CHUNK_DUMP") != "" {165 _ = os.WriteFile(fmt.Sprintf("%s/mosaic-chunk-%d.json", os.TempDir(), p.Chunk), in, 0o644)166 }167 t0 := time.Now()168 runErr := cmd.Run()169 if Verbose {170 // the chunk's own failure lines, attributed, then its cost171 for _, line := range strings.Split(errb.String(), "\n") {172 if strings.Contains(line, "attempt") || strings.Contains(line, "released") {173 fmt.Fprintf(os.Stderr, "field: chunk %d: %s\n", p.Chunk, line)174 }175 }176 rss := int64(0)177 if cmd.ProcessState != nil {178 if ru, ok := cmd.ProcessState.SysUsage().(*syscall.Rusage); ok {179 rss = int64(ru.Maxrss)180 if runtime.GOOS == "darwin" {181 rss /= 1 << 20 // bytes on darwin182 } else {183 rss /= 1 << 10 // kilobytes elsewhere184 }185 }186 }187 fixedCells := 0188 for _, ft := range p.Fixed {189 fixedCells += ft.Tile.Cells190 }191 line := fmt.Sprintf("field: chunk %d region %v fixed %d tiles %d cells: wall %s, max rss %d MB", p.Chunk, p.Region, len(p.Fixed), fixedCells, time.Since(t0).Round(time.Millisecond), rss)192 if runErr == nil {193 var o Output194 if json.Unmarshal(out.Bytes(), &o) == nil {195 st := o.Stats196 line += fmt.Sprintf(", tiles %d, fill %d ms, givens %d ms, settle %d ms, sat %d calls, waves %d, attempts %d, regivens %d, redraws %d, fill_retries %d",197 len(o.Manifest.Tiles), st.FillMs, st.GivensMs, st.SettleMs, st.SatCalls, st.Waves, st.Attempts, st.Regivens, st.Redraws, st.FillRetries)198 }199 }200 fmt.Fprintln(os.Stderr, line)201 }202 if err := runErr; err != nil {203 msg := strings.TrimSpace(errb.String())204 if i := strings.LastIndex(msg, "\n"); i >= 0 {205 msg = msg[i+1:]206 }207 return nil, fmt.Errorf("chunk process: %v: %s", err, msg)208 }209 var o Output210 if err := json.Unmarshal(out.Bytes(), &o); err != nil {211 return nil, fmt.Errorf("chunk output: %w", err)212 }213 return &o, nil214 }215}216217func chunkNonce(seed string, chunk, retry int) string {218 h := sha256.Sum256([]byte(fmt.Sprintf("mosaic-chunk|%s|%d|%d", seed, chunk, retry)))219 return hex.EncodeToString(h[:8])220}221222// provisional ids keep chunks' tiles apart before the merge renumbers223const idStride = 100000224225// errEmptyRing: every chunk of the batch generated nothing (its region was226// already covered by context); for a ring that means exhausted.227var errEmptyRing = errors.New("empty ring")228229// latticeChunk is one chunk to generate: its lattice coordinates, region230// and colour; idx orders it deterministically within its batch.231type latticeChunk struct {232 cx, cy int233 idx int234 region [4]int235 colour int236}237238// GenerateField generates the initial chunk grid (colour by colour, Jobs239// at a time) with the runner and merges it into one Output.240func GenerateField(ctx context.Context, p FieldParams, run Runner) (*Output, error) {241 p = fieldDefaults(p)242 if p.Kind != "scatter" {243 return nil, fmt.Errorf("field chunks must be scatter, not %q", p.Kind)244 }245 plan, err := PlanField(p)246 if err != nil {247 return nil, err248 }249 start := time.Now()250 var chunks []latticeChunk251 for i, r := range plan.Regions {252 chunks = append(chunks, latticeChunk{cx: i % p.Chunks, cy: i / p.Chunks, idx: i, region: r, colour: plan.Colour[i]})253 }254 outs, err := generateChunks(ctx, p, plan, chunks, nil, nil, p.Seed, 0, run)255 if err != nil {256 return nil, err257 }258 out, err := mergeChunks(p, plan, chunks, outs, nil, nil, 0)259 if err != nil {260 return nil, err261 }262 out.Manifest.Field.Occupied = make([][2]int, 0, len(chunks))263 for _, c := range chunks {264 out.Manifest.Field.Occupied = append(out.Manifest.Field.Occupied, [2]int{c.cx, c.cy})265 }266 out.Stats.Ms = time.Since(start).Milliseconds()267 return out, nil268}269270func fieldDefaults(p FieldParams) FieldParams {271 if p.Kind == "" {272 p.Kind = "scatter"273 }274 if p.Jobs <= 0 {275 p.Jobs = 4276 }277 if p.Retries <= 0 {278 p.Retries = 3279 }280 if p.Band <= 0 {281 p.Band = 16282 }283 return p284}285286// GrowField generates the ring of lattice chunks around the field's287// occupied set (ring 1 is the 8-neighbourhood; ring 2 the one beyond) and288// returns a delta Output: only the new tiles (ids continuing from the289// existing ones; overlaps may reference old ids), the merged solution,290// the new chunks' seed tiles, and the field record with the ring added.291// No existing tile's cell changes; that is asserted. When no chunk of the292// ring fits on the field, the delta is empty and Stats.Exhausted is set.293func GrowField(ctx context.Context, state *Output, p FieldParams, ring int, run Runner) (*Output, error) {294 if state.DaySeed != "" {295 p.Seed = state.DaySeed296 }297 p = fieldDefaults(p)298 plan, err := planOf(&state.Manifest)299 if err != nil {300 return nil, err301 }302 if len(state.Solution) != plan.G {303 return nil, fmt.Errorf("state has %d solution rows for G=%d", len(state.Solution), plan.G)304 }305 if ring < 1 {306 ring = 1307 }308 f := state.Manifest.Field309 occupied := map[[2]int]bool{}310 for _, c := range f.Occupied {311 occupied[c] = true312 }313 start := time.Now()314 k := f.Grown + 1315 // the ring: lattice cells within `ring` of the occupied set, not316 // occupied, whose region fits; the innermost band first so every new317 // chunk touches context318 var chunks []latticeChunk319 for d := 1; d <= ring; d++ {320 var layer [][2]int321 seenL := map[[2]int]bool{}322 for c := range occupied {323 for dy := -d; dy <= d; dy++ {324 for dx := -d; dx <= d; dx++ {325 n := [2]int{c[0] + dx, c[1] + dy}326 if occupied[n] || seenL[n] {327 continue328 }329 seenL[n] = true330 layer = append(layer, n)331 }332 }333 }334 sort.Slice(layer, func(a, b int) bool {335 if layer[a][1] != layer[b][1] {336 return layer[a][1] < layer[b][1]337 }338 return layer[a][0] < layer[b][0]339 })340 for _, n := range layer {341 r, ok := plan.regionOf(n[0], n[1])342 if !ok {343 continue344 }345 chunks = append(chunks, latticeChunk{cx: n[0], cy: n[1], idx: len(chunks), region: r, colour: colourOf(n[0], n[1])})346 occupied[n] = true347 }348 }349 base := len(state.Manifest.Tiles)350 if len(chunks) == 0 {351 out := &Output{Manifest: state.Manifest, Solution: state.Solution, Stats: Stats{Exhausted: true}}352 out.Manifest.Tiles = nil353 out.Manifest.Field = cloneField(f)354 out.Manifest.Field.SeedTiles = nil355 out.Manifest.Field.Grown = k356 out.Stats.Ms = time.Since(start).Milliseconds()357 return out, nil358 }359 salt := fmt.Sprintf("%s|grow|%d", p.Seed, k)360 outs, err := generateChunks(ctx, p, plan, chunks, state.Manifest.Tiles, state.Solution, salt, base, run)361 if err != nil {362 return nil, err363 }364 out, err := mergeChunks(p, plan, chunks, outs, state.Manifest.Tiles, state.Solution, base)365 if errors.Is(err, errEmptyRing) {366 out := &Output{Manifest: state.Manifest, Solution: state.Solution, Stats: Stats{Exhausted: true}}367 out.Manifest.Tiles = nil368 out.Manifest.Field = cloneField(f)369 out.Manifest.Field.SeedTiles = nil370 out.Manifest.Field.Grown = k371 out.Stats.Ms = time.Since(start).Milliseconds()372 return out, nil373 }374 if err != nil {375 return nil, err376 }377 out.Manifest.ID, out.Manifest.SeedCommit = state.Manifest.ID, state.Manifest.SeedCommit378 out.Manifest.Packer = state.Manifest.Packer379 out.Manifest.Field.Origin = f.Origin380 out.Manifest.Field.Occupied = append(append([][2]int(nil), f.Occupied...), func() [][2]int {381 var v [][2]int382 for _, c := range chunks {383 v = append(v, [2]int{c.cx, c.cy})384 }385 return v386 }()...)387 out.Manifest.Field.Grown = k388 out.Stats.Ms = time.Since(start).Milliseconds()389 return out, nil390}391392func cloneField(f *FieldPublic) *FieldPublic {393 c := *f394 c.SeedTiles = append([]int(nil), f.SeedTiles...)395 c.Occupied = append([][2]int(nil), f.Occupied...)396 return &c397}398399// ApplyGrowth merges a growth delta into a field state: tiles appended,400// solution replaced, field record replaced. Old tiles gain the reverse401// overlap entries the delta's new tiles declare against them.402func ApplyGrowth(state, delta *Output) (*Output, error) {403 if len(delta.Manifest.Tiles) > 0 && delta.Manifest.Tiles[0].ID != len(state.Manifest.Tiles) {404 return nil, fmt.Errorf("delta's first tile id %d, state has %d tiles", delta.Manifest.Tiles[0].ID, len(state.Manifest.Tiles))405 }406 out := *state407 out.Manifest.Tiles = append(append([]TilePublic(nil), state.Manifest.Tiles...), delta.Manifest.Tiles...)408 out.Tiles = append(append([]TilePrivate(nil), state.Tiles...), delta.Tiles...)409 // deep-copy old overlap lists before extending them410 for k := range state.Manifest.Tiles {411 out.Manifest.Tiles[k].Overlaps = append([]OverlapPublic(nil), state.Manifest.Tiles[k].Overlaps...)412 }413 for _, t := range delta.Manifest.Tiles {414 for _, ov := range t.Overlaps {415 if ov.With < len(state.Manifest.Tiles) {416 o := &out.Manifest.Tiles[ov.With]417 o.Overlaps = append(o.Overlaps, OverlapPublic{With: t.ID, Cells: ov.Cells})418 }419 }420 }421 // (old lists are appended to, never reordered: pins index them)422 out.Solution = delta.Solution423 if delta.Manifest.Field != nil {424 out.Manifest.Field = cloneField(delta.Manifest.Field)425 out.Manifest.Field.SeedTiles = append(append([]int(nil), state.Manifest.Field.SeedTiles...), delta.Manifest.Field.SeedTiles...)426 }427 out.Manifest.Rules.SeamClues = state.Manifest.Rules.SeamClues || delta.Manifest.Rules.SeamClues428 out.Layout = delta.Layout429 out.Layout.Tiles = len(out.Manifest.Tiles)430 return &out, nil431}432433// generateChunks runs the chunks colour by colour, Jobs at a time; every434// chunk sees the base tiles and the finished chunks intersecting its435// region as fixed context. Provisional ids: idBase + (idx+1)*idStride.436func generateChunks(ctx context.Context, p FieldParams, plan FieldPlan, chunks []latticeChunk, baseTiles []TilePublic, baseSol []string, salt string, idBase int, run Runner) ([]*Output, error) {437 outs := make([]*Output, len(chunks))438 var mu sync.Mutex439 tBatch := time.Now()440 defer func() {441 if Verbose {442 fmt.Fprintf(os.Stderr, "field: batch %s: %d chunks, jobs %d, wall %s\n", salt, len(chunks), p.Jobs, time.Since(tBatch).Round(time.Millisecond))443 }444 }()445 for colour := 0; colour < 4; colour++ {446 var idx []int447 for i, c := range chunks {448 if c.colour == colour {449 idx = append(idx, i)450 }451 }452 if len(idx) == 0 {453 continue454 }455 fixedFor := make([][]FixedTile, len(chunks))456 for _, i := range idx {457 fixedFor[i] = contextFor(chunks[i].region, outs, baseTiles, baseSol)458 }459 errs := make([]error, len(chunks))460 sem := make(chan struct{}, p.Jobs)461 var wg sync.WaitGroup462 for _, i := range idx {463 wg.Add(1)464 sem <- struct{}{}465 go func(i int) {466 defer wg.Done()467 defer func() { <-sem }()468 var last error469 for retry := 0; retry < p.Retries; retry++ {470 if ctx.Err() != nil {471 last = ctx.Err()472 break473 }474 gp := Params{475 Seed: p.Seed, Nonce: chunkNonce(salt, chunks[i].idx, retry), G: plan.G, Kind: p.Kind,476 Coverage: p.Coverage, Compound: p.Compound,477 Region: chunks[i].region, Fixed: fixedFor[i], IDBase: idBase + (chunks[i].idx+1)*idStride, Chunk: chunks[i].idx,478 }479 o, err := run(ctx, gp)480 if err == nil {481 mu.Lock()482 outs[i] = o483 mu.Unlock()484 last = nil485 break486 }487 last = err488 if Verbose {489 fmt.Fprintf(os.Stderr, "field: chunk (%d,%d) retry %d: %v\n", chunks[i].cx, chunks[i].cy, retry, err)490 }491 }492 errs[i] = last493 }(i)494 }495 wg.Wait()496 for _, i := range idx {497 if errs[i] != nil {498 return nil, fmt.Errorf("chunk (%d,%d) (colour %d, region %v) failed after %d packings: %w", chunks[i].cx, chunks[i].cy, colour, chunks[i].region, p.Retries, errs[i])499 }500 }501 }502 return outs, nil503}504505func fixedTile(t TilePublic, sol []string) FixedTile {506 pt := pack.Tile{ID: t.ID, X0: t.X0, Y0: t.Y0, W: t.W, H: t.H, Rects: t.Rects, Cells: t.Cells, M: t.M}507 sh := rules.Shape{X0: t.X0, Y0: t.Y0, W: t.W, H: t.H, Rects: t.Rects}508 cells := make([]rules.Cell, t.W*t.H)509 for li, ok := range sh.Mask() {510 cells[li] = rules.Empty511 if ok {512 cells[li] = rules.Cell(sol[t.Y0+li/t.W][t.X0+li%t.W] - '0')513 }514 }515 return FixedTile{Tile: pt, Cells: cells}516}517518func intersects(t TilePublic, r [4]int) bool {519 return !(t.X0 >= r[0]+r[2] || t.X0+t.W <= r[0] || t.Y0 >= r[1]+r[3] || t.Y0+t.H <= r[1])520}521522// contextFor collects the tiles intersecting a region as fixed context:523// the base field's tiles and the finished chunks' tiles.524func contextFor(r [4]int, outs []*Output, baseTiles []TilePublic, baseSol []string) []FixedTile {525 var fixed []FixedTile526 for _, t := range baseTiles {527 if intersects(t, r) {528 fixed = append(fixed, fixedTile(t, baseSol))529 }530 }531 for _, o := range outs {532 if o == nil {533 continue534 }535 for _, t := range o.Manifest.Tiles {536 if intersects(t, r) {537 fixed = append(fixed, fixedTile(t, o.Solution))538 }539 }540 }541 sort.Slice(fixed, func(a, b int) bool { return fixed[a].Tile.ID < fixed[b].Tile.ID })542 return fixed543}544545// mergeChunks renumbers the chunks' tiles densely from idBase in chunk546// order, remaps overlaps (adding the reverse entry a chunk could not know547// about, among the new tiles), overlays the solutions on the base and548// assembles one manifest holding only the new tiles.549func mergeChunks(p FieldParams, plan FieldPlan, chunks []latticeChunk, outs []*Output, baseTiles []TilePublic, baseSol []string, idBase int) (*Output, error) {550 G := plan.G551 final := map[int]int{}552 for _, t := range baseTiles {553 final[t.ID] = t.ID554 }555 var tiles []TilePublic556 var priv []TilePrivate557 var seedTiles []int558 var chunkStats []Stats559 for i, o := range outs {560 if o == nil {561 return nil, fmt.Errorf("chunk %d missing", i)562 }563 for k, t := range o.Manifest.Tiles {564 final[t.ID] = idBase + len(tiles)565 tiles = append(tiles, t)566 priv = append(priv, o.Tiles[k])567 }568 if len(o.Manifest.Tiles) > 0 {569 seedTiles = append(seedTiles, final[o.Manifest.SeedTile])570 }571 chunkStats = append(chunkStats, o.Stats)572 }573 type key struct{ a, b int }574 seen := map[key]bool{}575 reverse := map[int][]OverlapPublic{}576 for k := range tiles {577 t := &tiles[k]578 id := idBase + k579 t.ID = id580 priv[k].ID = id581 ovs := make([]OverlapPublic, len(t.Overlaps))582 for j, ov := range t.Overlaps {583 w, ok := final[ov.With]584 if !ok {585 return nil, fmt.Errorf("tile %d overlaps unknown tile %d", id, ov.With)586 }587 ovs[j] = OverlapPublic{With: w, Cells: ov.Cells}588 seen[key{id, w}] = true589 }590 t.Overlaps = ovs591 }592 for _, t := range tiles {593 for _, ov := range t.Overlaps {594 if ov.With >= idBase && !seen[key{ov.With, t.ID}] {595 reverse[ov.With] = append(reverse[ov.With], OverlapPublic{With: t.ID, Cells: ov.Cells})596 }597 }598 }599 // pins index into a tile's overlap list, so an existing list is never600 // reordered: reverse entries are appended (sorted among themselves)601 for w, extra := range reverse {602 sort.Slice(extra, func(a, b int) bool { return extra[a].With < extra[b].With })603 tiles[w-idBase].Overlaps = append(tiles[w-idBase].Overlaps, extra...)604 }605 // solution overlay on the base; a base cell never changes606 sol := make([][]byte, G)607 for y := range sol {608 if baseSol != nil {609 sol[y] = []byte(baseSol[y])610 } else {611 sol[y] = bytes.Repeat([]byte{'.'}, G)612 }613 }614 for i, o := range outs {615 if len(o.Solution) != G {616 return nil, fmt.Errorf("chunk %d: %d solution rows for G=%d", i, len(o.Solution), G)617 }618 for y, row := range o.Solution {619 for x := 0; x < G; x++ {620 if row[x] == '.' {621 continue622 }623 if sol[y][x] != '.' && sol[y][x] != row[x] {624 return nil, fmt.Errorf("chunks disagree at (%d,%d)", x, y)625 }626 sol[y][x] = row[x]627 }628 }629 }630 nonce := chunkNonce(p.Seed, -1, 0)631 _ = nonce632 commit := DayCommit(p.Seed)633 dayKey := DayKey(p.Seed)634 mByN := map[string]int{}635 for n := pack.MinN; n <= pack.MaxN; n += 2 {636 mByN[fmt.Sprint(n)] = pack.MByN(n)637 }638 seamClues := false639 attempts, regivens, backjumps, waves := 0, 0, 0, 0640 for _, o := range outs {641 seamClues = seamClues || o.Manifest.Rules.SeamClues642 attempts += o.Stats.Attempts643 regivens += o.Stats.Regivens644 backjumps += o.Stats.Backjumps645 waves += o.Stats.Waves646 }647 covered := 0648 for y := range sol {649 for x := range sol[y] {650 if sol[y][x] != '.' {651 covered++652 }653 }654 }655 if len(tiles) == 0 {656 return nil, errEmptyRing657 }658 out := &Output{659 Manifest: Manifest{660 Version: 3,661 HashScheme: "mosaic-tile-v1",662 HashKeyCommit: HashKeyCommit(dayKey),663 ID: commit,664 SeedCommit: commit,665 G: G,666 Packer: PackerInfo{Kind: "field", Params: map[string]any{"kind": p.Kind, "chunks": p.Chunks, "chunk_g": plan.ChunkG, "band": plan.Band, "coverage": p.Coverage, "compound": p.Compound}},667 MByN: mByN,668 Rules: Rules{Overlaps: true, CrossTile: false, SeamClues: seamClues},669 SeedTile: seedTiles[0],670 Tiles: tiles,671 K: "unmeasured",672 Generator: generatorHash(),673 Field: &FieldPublic{Chunks: plan.Chunks, ChunkG: plan.ChunkG, Band: plan.Band, Origin: plan.Origin, SeedTiles: seedTiles},674 },675 Tiles: priv,676 Stats: Stats{Attempts: attempts, Regivens: regivens, Backjumps: backjumps, Waves: waves},677 Layout: pack.Report{Tiles: idBase + len(tiles), Coverage: float64(covered) / float64(G*G)},678 ChunkStats: chunkStats,679 DaySeed: p.Seed,680 HashKey: hex.EncodeToString(dayKey),681 }682 for y := range sol {683 out.Solution = append(out.Solution, string(sol[y]))684 }685 rehashTiles(out, dayKey)686 return out, nil687}688689// rehashTiles recomputes every emitted tile's commitment over the merged690// output: a chunk hashes with its local ids and coordinates, the field691// renumbers and translates them, and the commitment binds the final id692// and position.693func rehashTiles(out *Output, key []byte) {694 G := out.Manifest.G695 for i := range out.Manifest.Tiles {696 tp := &out.Manifest.Tiles[i]697 sh := rules.Shape{X0: tp.X0, Y0: tp.Y0, W: tp.W, H: tp.H, Rects: tp.Rects}698 given := map[int]bool{}699 if i < len(out.Tiles) {700 for _, g := range out.Tiles[i].Givens {701 given[g[0]] = true702 }703 }704 sol := make([]byte, tp.W*tp.H)705 giv := make([]byte, tp.W*tp.H)706 for ly := 0; ly < tp.H; ly++ {707 for lx := 0; lx < tp.W; lx++ {708 li := ly*tp.W + lx709 sol[li], giv[li] = '.', '.'710 if !sh.Contains(tp.X0+lx, tp.Y0+ly) {711 continue712 }713 sol[li] = out.Solution[tp.Y0+ly][tp.X0+lx]714 giv[li] = '0'715 if given[(tp.Y0+ly)*G+tp.X0+lx] {716 giv[li] = '1'717 }718 }719 }720 tp.Hash = TileHash(key, out.Manifest.ID, tp.ID, tp.X0, tp.Y0, tp.W, tp.H, tp.Rects, sol, giv)721 }722}