Merge pull request #1487 from bryanthaboi/dev

[release 0.2.0]
This commit is contained in:
bryanthaboi
2026-08-17 13:10:08 -04:00
committed by GitHub
113 changed files with 6984 additions and 329 deletions
+118
View File
@@ -0,0 +1,118 @@
# AI Disclosure
This is a disclosure of the use of AI in this project.
## AI Use
Anyone who demands the dislosure of how AI was used in an engineering project,
has no idea what AI is, or how it works.
AI was used in this project as a tool. Several contributors used AI in their
commits, and so you will see like 7 commits by Claude or Codex or Cursor.
However those commits were reviewed by human beings, and it was declared that
the exact same fix would have been done by a human, so they were accepted.
AI was not used to make decisions, or to create the project.
If you would like to read more, well then continue reading:
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque ante leo, luctus in semper a, maximus ut est. Vivamus nec magna vitae quam luctus suscipit nec eu orci. Vestibulum ut felis a dolor cursus vulputate. Phasellus pharetra elementum sollicitudin. Aenean elementum imperdiet ultrices. In risus mauris, scelerisque sed viverra in, iaculis non eros. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin quam justo, maximus sit amet fringilla in, tristique eget sapien.
Pellentesque rhoncus, erat nec elementum ultricies, augue purus suscipit augue, in condimentum nisl enim vel velit. Etiam at semper turpis. Vestibulum ipsum magna, ultrices non sem ut, hendrerit bibendum mi. Curabitur in varius sapien. Morbi posuere bibendum ex, at ultrices orci. Fusce feugiat ultrices varius. Suspendisse sed ante ligula. Sed dignissim lorem est, nec tristique arcu commodo sed. Cras consectetur sapien dolor, vitae finibus enim lacinia id.
Donec quis magna est. Maecenas dui arcu, venenatis sit amet libero nec, lacinia eleifend leo. Quisque lobortis vulputate lacus a elementum. Proin nec metus lectus. Donec eu auctor sem, at finibus ipsum. Curabitur eget dignissim justo. Donec lobortis leo eu arcu tristique, in volutpat augue eleifend. Morbi lacinia a risus in suscipit. Maecenas suscipit est eu interdum dictum. Cras in nulla imperdiet, dapibus mauris posuere, facilisis velit. Nunc dapibus, leo quis interdum tempor, elit mi mattis dolor, sagittis dictum mi urna sed lectus. Maecenas elementum, mauris id molestie dapibus, diam arcu egestas erat, at tempor justo orci vitae nibh. Etiam sagittis facilisis erat a vulputate. Praesent condimentum ac odio quis sollicitudin.
Fusce vitae orci vestibulum, sagittis dolor non, cursus urna. Morbi eleifend pretium pellentesque. Pellentesque ornare elementum sem in imperdiet. Maecenas dapibus, erat et lobortis porttitor, velit magna auctor odio, quis interdum elit est eu justo. In posuere euismod odio, in porttitor magna iaculis eget. In id quam pulvinar, ultrices dolor in, pellentesque dolor. Nunc varius ante at felis dictum, id porttitor sem efficitur. Integer pretium dignissim commodo. Suspendisse in est a arcu blandit faucibus. Donec quis lacus mollis, tincidunt nunc quis, suscipit nunc. Nunc non arcu dignissim, dignissim sem in, finibus neque. Aliquam non porta eros. Donec et pretium augue, non cursus eros.
Nunc at dignissim nisi. Nam nec metus augue. Proin nulla sapien, tristique a purus vel, vulputate commodo mi. Sed id erat leo. Quisque ullamcorper a nisl id molestie. Aliquam erat volutpat. Donec eget hendrerit mauris. Fusce tincidunt nisl a lorem tincidunt dapibus. Nam volutpat rhoncus tortor.
Nunc et sapien enim. Proin at nunc a nulla maximus consectetur nec eget tortor. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Mauris orci odio, sodales et elementum laoreet, porta at lacus. Maecenas vestibulum lectus risus, pulvinar scelerisque dolor posuere viverra. Proin gravida tellus vitae accumsan dignissim. Nunc non sapien aliquet ex cursus ultricies ac quis diam. Sed luctus feugiat risus eu tincidunt. Duis auctor lacinia fringilla. Donec pretium cursus magna a feugiat. Duis tristique, leo vulputate semper iaculis, est ipsum dapibus lacus, et molestie nulla enim nec ex. Nunc non feugiat neque.
Fusce euismod egestas elit ut pretium. Nulla eros quam, auctor sit amet faucibus eu, scelerisque eu neque. Sed nisi felis, lobortis in sapien a, tempor efficitur nunc. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Pellentesque ut justo est. Sed maximus, tellus in bibendum posuere, quam augue finibus eros, sed gravida arcu diam quis lectus. Donec eu placerat ligula. Ut quis imperdiet lorem. Maecenas a mi ac augue semper sodales. Curabitur in justo velit. Praesent et felis quis enim porttitor sagittis.
Nulla sed sagittis felis, sit amet placerat tortor. Ut metus est, sollicitudin ac turpis quis, aliquet congue lorem. Fusce auctor erat non convallis aliquet. Nullam sodales rutrum tellus ac malesuada. Quisque sem diam, iaculis in ultricies sit amet, fermentum quis sem. Integer condimentum placerat purus non lacinia. Integer hendrerit ultricies tellus, at dignissim nibh. Suspendisse accumsan eget tortor nec cursus. Proin accumsan rhoncus leo, eget pretium est tristique ac.
Sed feugiat sed diam a porta. Nullam varius lacus at fermentum fringilla. Morbi pharetra scelerisque pharetra. Nulla placerat vitae ligula non efficitur. Suspendisse quam dui, rutrum eget nulla eu, semper eleifend ante. Aenean ut condimentum arcu. Suspendisse auctor metus non sem ornare, vel tincidunt odio vehicula. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.
Donec vel metus ut libero sollicitudin posuere a nec nulla. Vivamus a scelerisque nisi. Aliquam eu sollicitudin tortor. Aliquam purus eros, blandit et quam et, pretium porttitor sem. Nunc iaculis arcu enim, et maximus sem malesuada in. Morbi nec nunc volutpat, semper diam sit amet, gravida elit. Vestibulum eu turpis vel lacus imperdiet congue. Donec rhoncus auctor sem.
Suspendisse eu lorem non dolor pretium finibus euismod quis dolor. Cras finibus egestas velit, commodo rutrum est placerat sit amet. Pellentesque vitae semper diam, sit amet auctor metus. Sed porttitor porttitor nunc, vel imperdiet neque volutpat quis. Sed hendrerit sapien et lacus imperdiet, nec hendrerit lorem sodales. Integer lobortis rutrum odio at ullamcorper. Aliquam tincidunt magna a tellus suscipit, at porta turpis dapibus. Ut ultricies auctor felis eu feugiat. Sed tempus sem et dictum fringilla. Nunc non pellentesque tortor. Suspendisse pulvinar, arcu ut imperdiet gravida, eros ex mattis mauris, vel ultricies est erat et dui. Praesent porttitor tortor et erat interdum efficitur. Phasellus et luctus lectus, et egestas ante. Praesent ex ipsum, rutrum id efficitur et, vulputate non tortor. Aenean maximus nunc ac purus sodales, et venenatis lacus laoreet.
Cras egestas ultrices dui, at tempor leo varius vitae. Donec porta, nisl nec ornare maximus, est arcu auctor mauris, varius elementum nisl arcu venenatis neque. Aenean metus quam, vestibulum eget justo non, hendrerit dapibus nunc. Vivamus diam ante, mattis sed nulla at, iaculis elementum magna. Sed massa diam, efficitur vel nunc sed, malesuada interdum tortor. Aliquam non neque aliquet ex imperdiet finibus eget ac neque. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae;
Pellentesque fringilla tortor metus, luctus commodo leo gravida et. Fusce nec turpis at lorem rutrum porta vel in justo. Curabitur mattis suscipit felis, id dapibus arcu ornare ut. Proin sapien felis, pulvinar ut tristique vitae, aliquet ac dui. Vestibulum a erat tellus. Vestibulum sagittis dolor eget augue egestas fringilla. Fusce et purus a nunc auctor dapibus vel sed dui. Fusce interdum, libero vel pellentesque rutrum, urna massa iaculis tellus, et aliquam lectus diam vel sem. Donec a nunc et dui semper gravida. Donec posuere, eros eu consequat efficitur, justo metus ullamcorper lectus, et molestie massa ipsum eu sapien. Mauris eu suscipit neque. Morbi convallis sit amet leo a scelerisque. Cras ultrices libero ac mattis accumsan. Nam gravida ligula id erat semper ornare. Duis consequat ut ipsum eu volutpat. Quisque egestas sollicitudin ullamcorper.
Cras pellentesque quam non neque porta fringilla. Integer elementum, augue mattis blandit consequat, enim ipsum finibus ex, quis finibus neque eros eu ex. Fusce at urna justo. Donec erat eros, maximus id mauris vel, rutrum rutrum sapien. Morbi sed rutrum ex. Suspendisse lacus velit, varius ut elementum vitae, finibus non enim. Suspendisse vehicula euismod ipsum, id consequat nulla sodales vel. Morbi eu sem id leo congue dapibus a nec velit. Sed nec neque quam. Etiam rhoncus id nulla id volutpat. Nulla facilisi. Donec non maximus enim. Aenean consequat, sapien sit amet malesuada rutrum, erat sem euismod sapien, et feugiat lectus mauris id velit.
Fusce sodales porttitor gravida. Proin placerat ante nec nibh tempor aliquam. Sed ut diam eu sem fringilla malesuada. Maecenas aliquam risus vel quam dictum, at iaculis nibh pretium. Mauris convallis quam vitae dolor varius suscipit. Etiam nec fermentum dui. Aliquam in magna tincidunt, consectetur quam eget, aliquam purus. Ut dictum aliquet finibus. Pellentesque vel lacinia felis. Nulla malesuada vestibulum varius. Sed quam diam, efficitur id felis in, volutpat bibendum erat. Praesent luctus vulputate urna at interdum. Aenean ac aliquam eros. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Morbi mollis, nisl vel consequat vehicula, massa tellus porta arcu, vel dictum sapien mauris sit amet nunc. Integer sed maximus neque, ac iaculis urna.
Integer non erat a leo euismod convallis quis eget magna. Morbi gravida ac urna sed ornare. Nunc vehicula mauris accumsan, ornare sem in, egestas mauris. Vestibulum vel vulputate felis. Nulla eu scelerisque diam. Suspendisse ac odio tempor nunc pellentesque hendrerit at a magna. Vivamus ultrices nunc ut orci fermentum pharetra. Nullam laoreet hendrerit ligula ut gravida. Proin scelerisque magna sit amet arcu malesuada, pharetra ultrices est molestie. Nullam pulvinar placerat dui, vitae hendrerit tortor luctus sed. Pellentesque elementum tellus eget arcu pulvinar varius.
Ut placerat, magna vitae tincidunt ultricies, est orci aliquet urna, at luctus augue erat vel ipsum. Fusce odio sem, venenatis vel consequat nec, bibendum sed dolor. Cras a sodales eros. Nullam eget dui congue, vehicula purus ut, condimentum dui. Maecenas libero ipsum, condimentum tincidunt nisi in, sodales lacinia ex. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus.
Duis gravida velit ac euismod suscipit. Proin sed ligula erat. Nullam eu ornare massa, non fringilla felis. Curabitur eu erat ex. Quisque sit amet dolor id arcu mattis scelerisque at at eros. In arcu nulla, fermentum non maximus eget, rhoncus in lacus. Nulla sit amet augue eu tortor vulputate congue. Vivamus laoreet condimentum tempus. Maecenas tempor, diam sed laoreet venenatis, mauris arcu lacinia enim, quis facilisis nunc turpis a ligula. Pellentesque quis placerat nisi, sit amet ullamcorper diam. Suspendisse a elementum elit, vel tristique dolor. Nam pretium ante tortor, vel tristique ipsum ultricies ut. Quisque non lectus imperdiet, placerat erat ac, pharetra tellus. In condimentum at magna a posuere. Aliquam et fringilla ipsum. Sed facilisis, nulla a finibus gravida, elit elit vulputate velit, dictum ornare est sapien a nisl.
Mauris eleifend vulputate felis sed mattis. Praesent id velit vitae ex porta pretium. Cras mollis malesuada justo, ut ornare quam placerat ac. Donec lobortis arcu tellus, luctus tempor mi malesuada quis. Maecenas condimentum libero vitae finibus malesuada. Vestibulum sollicitudin fringilla diam eget egestas. Sed vulputate urna nec ipsum maximus hendrerit. Maecenas blandit ex ut massa sodales, vitae tincidunt lorem ullamcorper. Phasellus vitae nisl ornare, cursus sem a, pulvinar arcu. Vestibulum faucibus risus nec tincidunt pellentesque. Pellentesque vel porttitor ex. Vivamus sollicitudin gravida lacus in suscipit. Aliquam urna neque, sodales quis quam ac, suscipit condimentum ante.
Morbi id arcu sit amet sapien ornare gravida eget quis sapien. In hac habitasse platea dictumst. In quis interdum ligula. Donec sed mi vulputate, scelerisque turpis vitae, interdum odio. Proin tristique condimentum arcu, et malesuada tellus convallis in. Fusce egestas maximus magna, sit amet convallis velit porttitor ut. Curabitur venenatis lacus ut blandit convallis. Phasellus scelerisque congue turpis eget vehicula. Nam venenatis mi sit amet rhoncus pretium. Nulla sed odio purus. Phasellus cursus id sapien ut feugiat. Duis et ipsum vel dui tempus porta. Curabitur non tortor consectetur, sollicitudin tellus mollis, sollicitudin lorem.
Ut quis ornare justo. Nunc aliquam, leo sit amet placerat placerat, dui nulla luctus dui, ac iaculis nisl orci id metus. Vestibulum nunc nunc, porta nec dictum id, feugiat et ante. Quisque lobortis, lacus tristique vestibulum rhoncus, massa nulla dignissim massa, at scelerisque massa nunc at velit. Donec eu ipsum nec dui luctus pulvinar et et turpis. Quisque eu neque erat. Donec varius egestas nunc, ut pretium libero tempor ac. Vestibulum pellentesque mi erat, et semper dolor semper vitae. Morbi enim dui, laoreet non venenatis sit amet, dignissim a orci. Sed id odio turpis. Phasellus non rutrum magna. Maecenas placerat arcu ultricies ultrices congue. Nulla quis neque ligula. Etiam in diam commodo, pharetra mauris ac, pretium nisi. Praesent sed nibh nec odio condimentum commodo vel vel lacus.
Pellentesque id libero vitae ex egestas pharetra placerat nec augue. Ut eget lobortis lorem, at vehicula sapien. Aliquam eu tincidunt ligula. Aenean et vestibulum dui, quis porttitor dui. Nullam quis dolor libero. Sed accumsan eros vitae nisi ornare congue. Aliquam nisi sapien, sollicitudin quis odio vel, pharetra maximus urna.
Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Morbi accumsan felis id urna malesuada, non vulputate velit maximus. Proin urna velit, viverra a metus vel, sollicitudin faucibus nulla. Vivamus ipsum lectus, pharetra sit amet varius vel, ullamcorper nec neque. Suspendisse potenti. Vivamus sit amet justo ac augue placerat hendrerit in eu felis. Integer luctus ex quam, in pellentesque eros interdum sed. Fusce finibus quis neque vitae efficitur. Pellentesque vulputate consectetur egestas. Integer a neque fermentum, tincidunt ligula id, gravida urna. Pellentesque ultrices, leo et suscipit accumsan, lorem nunc porta dui, non congue ligula leo ut urna. Duis vehicula risus in mi eleifend luctus. Duis convallis, mi faucibus pellentesque cursus, libero mauris varius sem, sit amet fermentum massa metus nec tellus. In tortor ligula, faucibus eu nibh id, lobortis viverra erat. Morbi non nisi suscipit, mollis enim at, convallis velit.
Pellentesque consequat imperdiet felis quis scelerisque. Duis aliquam mollis nibh quis tincidunt. Vivamus elit odio, blandit quis volutpat at, blandit nec tortor. Cras maximus ex at odio maximus, dapibus condimentum risus malesuada. Pellentesque viverra orci at ante commodo, quis posuere sapien efficitur. Nunc tristique imperdiet diam elementum lobortis. Fusce velit dui, ultrices id ante pharetra, fermentum egestas augue. Curabitur ante augue, vestibulum non magna quis, feugiat pulvinar diam. Suspendisse sagittis dui a tellus scelerisque, ut tincidunt neque accumsan. Cras pharetra metus vel eros tincidunt, vel tincidunt lacus egestas. Donec eget pellentesque sapien. Cras condimentum in justo pulvinar feugiat. Quisque malesuada ac odio eget rhoncus. Fusce posuere justo sed finibus ornare.
Duis a auctor tortor. Pellentesque lobortis auctor risus, ultrices varius mi cursus quis. Sed dui nulla, mattis sit amet justo mattis, condimentum commodo justo. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Pellentesque hendrerit diam vel lacinia volutpat. Sed et luctus nunc, vitae congue arcu. Aenean placerat tincidunt ipsum. Ut molestie orci eu dapibus viverra. Cras sodales ullamcorper augue, at aliquam ex. Sed et justo augue.
Nullam feugiat risus et turpis faucibus, vel tincidunt nulla consequat. Aliquam libero erat, pellentesque sit amet tellus in, tempor ornare nisl. Donec viverra eget magna non pharetra. Cras sollicitudin, justo ut porttitor venenatis, risus nulla auctor est, at commodo sem urna in mi. Vestibulum mattis sapien vel nibh pulvinar, vel dignissim lacus cursus. Maecenas vel dictum tortor, ac ultricies justo. Praesent quis venenatis nisl. Morbi a diam fringilla, auctor lectus sed, varius est. Praesent faucibus auctor dolor, a commodo nisi mattis id. Fusce porta molestie ultrices. Sed pulvinar, leo ac consectetur hendrerit, erat velit gravida enim, eu blandit ligula justo sit amet neque.
Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Aenean libero risus, porttitor in congue vel, consequat sed felis. Vivamus eget blandit ante. Vivamus vitae mattis massa. In ut dolor sit amet tellus sollicitudin mattis. Mauris iaculis nisl neque, in gravida lectus ultricies nec. Fusce vehicula vehicula lacinia. Fusce viverra sed nisl id rhoncus. Donec sed porta mi. Nam tempus purus non massa tincidunt iaculis. Morbi viverra massa ut gravida vestibulum.
Proin dapibus mi a libero sagittis, id vehicula nulla iaculis. Proin a enim in tortor tincidunt egestas. Integer finibus neque eu nibh pretium, et pellentesque urna finibus. Integer a sollicitudin mauris, at convallis erat. Nullam sit amet lectus sed turpis commodo efficitur. Nulla nec turpis dapibus, suscipit diam quis, vulputate urna. Aenean sed posuere justo.
Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Sed luctus purus nibh, id imperdiet risus pulvinar in. Quisque auctor sem lacinia turpis mollis, nec pretium ipsum suscipit. Aliquam sed metus sagittis, mollis nisi eget, hendrerit libero. Nulla sodales erat semper nisl condimentum, ultricies rhoncus lacus commodo. Ut suscipit libero augue, non vulputate dui tristique vel. Praesent convallis efficitur est, sed tincidunt mauris aliquet in. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.
Etiam a vehicula purus. Curabitur lacus erat, ultrices et dictum id, posuere ut risus. Etiam at auctor leo. Pellentesque eleifend est a metus lacinia rutrum. Nullam justo ligula, tempor non facilisis vel, volutpat sed nulla. Integer non dolor consequat, dapibus lectus eu, luctus turpis. Aenean enim erat, sagittis vitae ornare bibendum, faucibus sit amet magna. Phasellus suscipit ultrices faucibus. Sed at risus molestie, viverra ipsum vel, bibendum lectus. Pellentesque molestie vitae risus non viverra. Phasellus eleifend massa id odio sagittis mattis. Nullam velit mauris, viverra quis fermentum sit amet, vestibulum ut ex. Suspendisse imperdiet, sapien sed sagittis pharetra, nisi nibh vulputate metus, quis mattis dolor nisi ut lorem. Praesent vestibulum nibh vulputate lacus pulvinar tempor. Vestibulum vulputate diam ligula, vitae efficitur enim dapibus non. Etiam at ornare enim.
Curabitur aliquet velit enim, euismod faucibus urna euismod sit amet. Vivamus viverra vulputate nulla, ut gravida neque rutrum in. Suspendisse potenti. Nulla vitae neque felis. Etiam eu erat ac nulla ornare volutpat. Quisque ut diam dui. Sed ut massa quis dolor volutpat eleifend. Duis posuere dolor sit amet varius auctor. Donec mollis malesuada erat, eu luctus libero viverra feugiat. Curabitur fermentum velit eu purus fringilla, consequat tincidunt diam rutrum. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc volutpat vel est in vehicula. Morbi euismod, tortor ut posuere ullamcorper, velit justo ultricies lorem, vitae tincidunt erat ante vitae sem. Praesent semper, quam eget condimentum finibus, metus leo imperdiet augue, nec fringilla sem nunc id sapien.
Aliquam vestibulum ante porta sem finibus, ut rhoncus elit sodales. Phasellus a lacus congue, sagittis nulla eget, cursus libero. Duis laoreet fringilla faucibus. Aenean gravida lorem sed fringilla facilisis. Pellentesque sodales urna lorem, non rutrum tortor vulputate eget. Duis a enim semper, iaculis sem ac, facilisis urna. Donec iaculis nulla sit amet dignissim volutpat. Mauris cursus dui id feugiat suscipit. Fusce tempor placerat nulla vitae vestibulum. Vivamus imperdiet blandit nulla, in aliquet justo viverra in. Cras malesuada molestie ligula sit amet volutpat. Praesent ornare orci sit amet rutrum eleifend. Ut placerat metus felis, id malesuada justo mollis eget. Etiam mi turpis, pulvinar in pulvinar in, tincidunt in neque.
In venenatis euismod neque, eu convallis diam semper ac. Mauris auctor mi non massa vestibulum viverra. Aenean non turpis sapien. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Praesent gravida feugiat interdum. Vivamus sit amet consequat ligula. Praesent dictum nunc ac sapien ullamcorper consectetur. Ut malesuada blandit neque.
Duis tincidunt mauris sit amet odio dictum accumsan. Suspendisse efficitur nibh magna, ac dapibus augue vulputate nec. Etiam lectus neque, sollicitudin quis sapien vitae, aliquet fermentum mi. Aenean interdum interdum rhoncus. Donec eu libero urna. Donec semper lacus eu nunc scelerisque, vitae viverra quam consectetur. Integer sagittis, nulla congue venenatis auctor, purus justo mollis metus, at rutrum magna arcu nec est. Maecenas felis lorem, consequat non cursus vitae, lobortis vitae nisi. Cras eget magna justo. Nullam sagittis tellus id luctus ultricies. Etiam a arcu efficitur, consectetur libero non, imperdiet turpis. Donec ac velit et nisl semper semper. Duis iaculis interdum nunc sed tempor.
Sed diam odio, sagittis non dignissim nec, accumsan ac diam. Fusce sit amet dui sit amet justo ultrices viverra. Sed vel massa suscipit nibh porttitor laoreet in id nunc. Nam quis libero vitae nunc blandit sollicitudin et a lorem. Duis urna arcu, accumsan sed dignissim sit amet, vulputate at ex. Mauris porttitor libero mauris, vel fringilla diam euismod quis. Sed varius placerat tellus vel efficitur. Phasellus pulvinar gravida magna. Nulla dignissim consectetur finibus.
Nunc quis aliquet nisi. Cras luctus bibendum eros ac dignissim. Aenean suscipit felis vitae elementum eleifend. Proin commodo nunc non diam dignissim, in tincidunt nulla ultrices. Vivamus faucibus quam scelerisque interdum finibus. Sed porttitor vehicula urna, in laoreet arcu condimentum non. Praesent ac lacus diam. Vivamus aliquam euismod risus, luctus dictum lectus sodales et. Proin quis velit ac massa tristique scelerisque.
Sed non dolor efficitur, tincidunt mi eget, sagittis tortor. Quisque at varius felis, at finibus sem. Vestibulum vel lectus tincidunt, pharetra diam sit amet, interdum nulla. Sed ut elit tortor. Nam tincidunt tempus aliquam. Vivamus rhoncus faucibus sapien eget facilisis. Aliquam erat volutpat. Phasellus placerat aliquam lacus, eget ultrices orci pretium hendrerit. Fusce vitae dolor sit amet ante condimentum placerat. Nunc varius risus id tellus mollis, euismod luctus sapien viverra. Donec sodales est vel massa suscipit, eu sollicitudin ante convallis. Curabitur eu condimentum velit. Sed pharetra euismod tincidunt.
Nam at libero eros. Quisque bibendum, ligula quis sagittis ullamcorper, eros leo consectetur ex, quis elementum dolor justo vitae mi. Maecenas et elementum erat, et auctor enim. Nunc at nibh fermentum, ullamcorper mi elementum, facilisis erat. Vestibulum vestibulum leo ut pellentesque placerat. Suspendisse imperdiet nisl vitae justo sodales pellentesque. Interdum et malesuada fames ac ante ipsum primis in faucibus. In faucibus pretium nunc, sed interdum lectus vestibulum quis. Vestibulum luctus viverra ex at efficitur. Etiam ac est lorem. Maecenas mollis, orci at rhoncus congue, nulla leo rutrum dui, et pellentesque orci ligula eget ipsum. Suspendisse fermentum nisi turpis, ut sollicitudin purus imperdiet non. Maecenas vitae quam ornare, porta sem quis, rhoncus neque. Donec mattis purus a erat tristique, ac mollis est convallis. Duis vitae ipsum viverra, condimentum ante vel, sagittis ex. Maecenas placerat odio libero, id interdum turpis fermentum at.
Praesent faucibus nulla eget vehicula accumsan. Nulla elementum ante a nibh venenatis hendrerit. Proin nec nunc mattis, imperdiet nisl rutrum, sollicitudin libero. Nullam bibendum dignissim faucibus. Sed eget tortor vitae sapien cursus faucibus nec et lectus. Duis pharetra non odio id consectetur. Suspendisse at est sem. Nunc mauris ligula, ultrices id ante non, venenatis mattis erat. Vivamus sit amet viverra nisl.
Sed cursus vel nisi in mattis. Nunc porttitor dictum leo ac euismod. Sed blandit ornare nunc id lobortis. Aenean convallis ligula at volutpat commodo. Vestibulum sit amet laoreet urna. Donec et pellentesque orci, ac egestas nulla. In accumsan venenatis porta. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Donec scelerisque, metus nec viverra pharetra, dolor libero dictum velit, id ullamcorper enim nisi eu nibh. Aliquam nec dapibus quam. Curabitur vulputate, libero sit amet tempor ullamcorper, libero purus congue quam, nec sollicitudin orci erat non massa. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas.
Aenean faucibus mollis placerat. Praesent lacinia venenatis turpis eu scelerisque. Nam tempus tortor a varius posuere. Cras ut viverra tortor. Cras facilisis mauris ut ante imperdiet, a malesuada justo luctus. Ut eu enim sit amet arcu porttitor pulvinar ac ac odio. Ut odio neque, molestie vitae ligula quis, dignissim viverra erat. In ullamcorper erat sed elementum varius. Sed in lacus maximus, euismod nisi vitae, tempus mi. Nunc tellus justo, auctor at luctus ac, feugiat sit amet dui.
Donec imperdiet purus lorem, sed venenatis dolor finibus non. Aenean lacus nunc, elementum nec arcu eget, faucibus elementum turpis. Aliquam lacinia massa ac quam efficitur, et tincidunt eros pretium. Fusce condimentum mi vel pharetra egestas. Quisque consectetur nibh vel leo dignissim sollicitudin. Duis ultrices felis ipsum, sed maximus arcu ornare vitae. Curabitur porttitor ligula in turpis facilisis, id venenatis augue ultricies. Phasellus vel dolor id tellus finibus sodales ut quis nisi. Integer id orci cursus erat tincidunt sagittis non in nunc. Pellentesque ligula lacus, vestibulum eu ante vel, facilisis viverra massa. Sed ut tincidunt metus, vel tristique est. Ut et cursus justo. Ut ac porttitor eros, at dictum felis. Phasellus ornare nisi sit amet risus varius, sed sollicitudin nulla ornare. Donec aliquam ipsum urna. Aliquam id bibendum magna, quis venenatis diam.
Duis tempor odio id iaculis egestas. Cras consequat neque ac posuere iaculis. Nulla tempus et nisi eu auctor. Vestibulum metus massa, dignissim ut metus eget, ullamcorper consectetur turpis. Integer vel est tellus. Ut ac vestibulum massa. Pellentesque nec venenatis erat. Nam vel pellentesque lectus.
Morbi a placerat est. Ut eleifend ante ut placerat porta. Donec sagittis semper leo, ut scelerisque nisi imperdiet feugiat. Mauris purus turpis, consequat ut fringilla ac, cursus eget augue. Fusce arcu dolor, sagittis et facilisis ut, scelerisque non lacus. Aliquam sit amet eleifend tellus. Mauris id est luctus, iaculis tortor eget, gravida justo. Suspendisse at tellus nisl. Nullam felis erat, vehicula eu porttitor bibendum, pulvinar et dui. Sed molestie lacus nec sagittis rutrum. Aliquam erat volutpat. Nullam ut aliquet eros. Sed feugiat, massa id pharetra auctor, leo turpis condimentum purus, sit amet volutpat sem nunc sed nisi.
Pellentesque feugiat ipsum at accumsan iaculis. Morbi et dui in lorem commodo hendrerit. Mauris tempor ex mollis mollis blandit. Cras eu turpis feugiat, suscipit velit quis, volutpat magna. Vestibulum varius ligula ut quam mollis, a volutpat nibh lobortis. Sed sodales euismod leo non suscipit. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Nullam ultricies sagittis justo, sit amet lacinia eros tincidunt tristique. Etiam faucibus turpis in lacus efficitur, vitae rutrum magna porttitor. Cras finibus eros vel ante semper, facilisis vestibulum erat accumsan. Ut mollis dui ut commodo varius. In et sem malesuada erat egestas pellentesque. Maecenas pulvinar sodales risus, at euismod ligula aliquet in. Quisque fringilla malesuada dui vel cursus. Curabitur eu ex vulputate, pretium elit non, sodales sapien. Integer egestas facilisis odio et pretium.
Integer eleifend, felis vitae faucibus tempus, tellus lectus placerat nunc, eu efficitur lacus mi vel nisl. Mauris commodo pretium feugiat. In aliquet nibh diam, ac egestas mauris consequat ut. Integer cursus, tortor pharetra pellentesque pulvinar, neque risus ultricies felis, et consequat felis eros at eros. Pellentesque fermentum velit ac sodales facilisis. Suspendisse vestibulum metus quis convallis lacinia. Donec in pharetra magna. Proin gravida dolor eget ligula lobortis sagittis.
Nullam consectetur ut massa id ultrices. Fusce consectetur at eros at mollis. Donec nec nibh fringilla, porttitor ipsum eget, aliquam neque. Quisque suscipit tortor in dui commodo, sed venenatis augue cursus. Etiam feugiat purus id justo elementum placerat. Sed interdum dictum nibh at sodales. Maecenas lobortis, metus ac sagittis lacinia, elit arcu varius felis, quis facilisis magna elit in leo. Proin condimentum orci sit amet dignissim imperdiet. Fusce sed iaculis felis. Maecenas sodales non magna vitae rutrum.
Nulla id ex massa. Sed vehicula sed quam non elementum. Aliquam luctus, enim vel molestie posuere, sem arcu laoreet justo, quis finibus nisl justo et magna. Vivamus malesuada elit in aliquam dignissim. Sed at tellus in orci vulputate ullamcorper. Integer magna sem, mattis id hendrerit non, tincidunt in est. Praesent posuere aliquet lobortis. Quisque euismod leo ut nisl pellentesque, et imperdiet dui dapibus.
Sed a erat nec risus pulvinar venenatis. Integer ultrices eros at aliquet efficitur. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Quisque ac maximus ante. Sed sodales, nisi et sagittis accumsan, diam odio consequat elit, et mollis dolor turpis tincidunt metus. Proin justo quam, tincidunt id convallis a, molestie hendrerit massa. Etiam pharetra turpis eu ultrices mollis.
Proin fermentum libero in purus cursus molestie. In varius magna eu ante maximus, eget rutrum felis iaculis. Maecenas hendrerit, diam eget vestibulum vehicula, est quam porta magna, a dictum urna mi ut libero. Vestibulum dictum quis lacus vitae eleifend. Integer sapien libero, pretium vitae euismod eget, semper eget ante. Vivamus mollis elementum odio vel hendrerit. Phasellus tristique, metus eget luctus tincidunt, ex enim faucibus ipsum, at dictum eros urna ac mi. Aenean imperdiet felis eu ultricies egestas. Vivamus fermentum convallis nisi, non sollicitudin felis posuere nec. Ut commodo sit amet felis semper dictum. Aenean accumsan, tellus id blandit aliquet, lorem nibh pellentesque mi, sit amet volutpat erat ligula ut est. Vivamus non posuere velit. Sed vel rutrum diam, non pulvinar nulla. Suspendisse quis gravida lectus, ac accumsan justo. Sed lobortis neque ante, a imperdiet nisl iaculis nec.
+4
View File
@@ -4,6 +4,10 @@ A native LÖVE2D recreation of Poke Red, Blue and Yellow. The engine and map
behavior are hand-written Lua; game data and graphics are decoded from a ROM
supplied by the player.
And before you say, "that's not a recomp", you're wrong. Recomp is an acronym. ***Reverse Engineering Causes Obsessive Mental Problems***
[Click Here for the AI Use Disclosure!](AIDisclosure.md)
> [!CAUTION]
> **We are NOT affiliated with the website `gen1recomp[.]com`** That website is not run by this project, was not authorized by us, and we have no idea who operates it. It is impersonating this project; do not download anything from it, and treat anything it hosts or claims as untrustworthy. Even if the site currently links back to this repository, the people behind it can change its content at any time, so nothing on it should ever be trusted. This GitHub repository and the Discord linked below are the only official sources for this project.
+10
View File
@@ -0,0 +1,10 @@
# gb_anim -- bundled touch skin
Bezel art and overlay layout from libretro's `common-overlays`
(`gamepads/gb_anim_portrait`), licensed CC-BY-4.0:
https://github.com/libretro/common-overlays
`overlay.cfg` is the upstream `gb_big.cfg`, unmodified. It ships as the
reference skin for the RetroArch-overlay loader in
`src/core/TouchSkin.lua`: a full-device bezel, per-button press art, a
screen viewport, and page switching between the DMG and Color shells.
Binary file not shown.

After

Width:  |  Height:  |  Size: 973 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 649 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 645 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 636 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

+89
View File
@@ -0,0 +1,89 @@
overlays = 2
overlay0_name = "GameBoy"
overlay0_overlay = img/gb_back.png
overlay0_full_screen = true
overlay0_normalized = true
overlay0_range_mod = 1.0
overlay0_alpha_mod = 0.001
overlay0_viewport = "0.0,0.0,1.0,0.505"
overlay0_viewport_fill = true
overlay1_name = "GameBoyColor"
overlay1_overlay = img/gbc_back.png
overlay1_full_screen = true
overlay1_normalized = true
overlay1_range_mod = 1.0
overlay1_alpha_mod = 0.001
overlay1_viewport = "0.0,0.0,1.0,0.505"
overlay1_viewport_fill = true
# GameBoy
overlay0_descs = 18
overlay0_desc0 = "left,0.12778,0.73417,radial,0.09630,0.04635"
overlay0_desc0_overlay = img/gb_left.png
overlay0_desc1 = "right,0.35370,0.73417,radial,0.09630,0.04635"
overlay0_desc1_overlay = img/gb_right.png
overlay0_desc2 = "up,0.24074,0.67063,radial,0.08241,0.05417"
overlay0_desc2_overlay = img/gb_up.png
overlay0_desc3 = "down,0.24074,0.79771,radial,0.08241,0.05417"
overlay0_desc3_overlay = img/gb_down.png
overlay0_desc4 = "left|up,0.09259,0.65188,rect,0.06481,0.03646"
overlay0_desc5 = "right|up,0.38704,0.65188,rect,0.06481,0.03646"
overlay0_desc6 = "left|down,0.09259,0.81750,rect,0.06481,0.03646"
overlay0_desc7 = "right|down,0.38704,0.81750,rect,0.06481,0.03646"
overlay0_desc8 = "a,0.87407,0.72417,radial,0.08889,0.05000"
overlay0_desc8_overlay = img/gb_a_b.png
overlay0_desc9 = "b,0.68148,0.76584,radial,0.08889,0.05000"
overlay0_desc9_overlay = img/gb_a_b.png
overlay0_desc10 = "a|b,0.77037,0.73417,radial,0.02963,0.01667"
overlay0_desc11 = "a|b,0.78518,0.75584,radial,0.02963,0.01667"
overlay0_desc12 = "start,0.66666,0.93000,radial,0.07037,0.03958"
overlay0_desc12_overlay = img/gb_start_select.png
overlay0_desc13 = "select,0.33333,0.93000,radial,0.07037,0.03958"
overlay0_desc13_overlay = img/gb_start_select.png
overlay0_desc14 = "menu_toggle,0.05000,0.52800,radial,0.041296,0.02323"
overlay0_desc14_overlay = img/menu.png
overlay0_desc15 = "overlay_next,0.95000,0.52800,radial,0.041296,0.02323"
overlay0_desc15_overlay = img/rotate.png
overlay0_desc15_next_target = "GameBoyColor"
overlay0_desc16 = "rewind,0.05000,0.97500,radial,0.041296,0.02323"
overlay0_desc16_overlay =
overlay0_desc17 = "hold_fast_forward,0.95000,0.97500,radial,0.041296,0.02323"
overlay0_desc17_overlay =
# GameBoyColor
overlay1_descs = 18
overlay1_desc0 = "left,0.14078,0.73417,radial,0.08530,0.04635"
overlay1_desc0_overlay = img/gbc_left.png
overlay1_desc1 = "right,0.34270,0.73417,radial,0.08530,0.04635"
overlay1_desc1_overlay = img/gbc_right.png
overlay1_desc2 = "up,0.24074,0.67863,radial,0.08241,0.04617"
overlay1_desc2_overlay = img/gbc_up.png
overlay1_desc3 = "down,0.24074,0.78971,radial,0.08241,0.04617"
overlay1_desc3_overlay = img/gbc_down.png
overlay1_desc4 = "left|up,0.09259,0.65188,rect,0.06481,0.03646"
overlay1_desc5 = "right|up,0.38704,0.65188,rect,0.06481,0.03646"
overlay1_desc6 = "left|down,0.09259,0.81750,rect,0.06481,0.03646"
overlay1_desc7 = "right|down,0.38704,0.81750,rect,0.06481,0.03646"
overlay1_desc8 = "a,0.87407,0.72417,radial,0.08889,0.05000"
overlay1_desc8_overlay = img/gbc_a.png
overlay1_desc9 = "b,0.68148,0.76584,radial,0.08889,0.05000"
overlay1_desc9_overlay = img/gbc_b.png
overlay1_desc10 = "a|b,0.77037,0.73417,radial,0.02963,0.01667"
overlay1_desc11 = "a|b,0.78518,0.75584,radial,0.02963,0.01667"
overlay1_desc12 = "start,0.66666,0.93000,radial,0.07037,0.03958"
overlay1_desc12_overlay = img/gbc_start_select.png
overlay1_desc13 = "select,0.33333,0.93000,radial,0.07037,0.03958"
overlay1_desc13_overlay = img/gbc_start_select.png
overlay1_desc14 = "menu_toggle,0.05000,0.52800,radial,0.041296,0.02323"
overlay1_desc14_overlay = img/menu.png
overlay1_desc15 = "overlay_next,0.95000,0.52800,radial,0.041296,0.02323"
overlay1_desc15_overlay = img/rotate.png
overlay1_desc15_next_target = "GameBoy"
overlay1_desc16 = "rewind,0.05000,0.97500,radial,0.041296,0.02323"
overlay1_desc16_overlay =
overlay1_desc17 = "hold_fast_forward,0.95000,0.97500,radial,0.041296,0.02323"
overlay1_desc17_overlay =
+10
View File
@@ -0,0 +1,10 @@
# tv_crt -- bundled desktop bezel
CRT television border from libretro's `common-overlays`
(`borders/tv-integer.cfg` + `borders/img/tv-integer.png`), licensed
CC-BY-4.0: https://github.com/libretro/common-overlays
`overlay.cfg` is the upstream file, unmodified. It is the reference
DESKTOP skin: 1920x1080, `descs = 0` (pure decoration, no touch buttons),
and a `viewport` naming the transparent screen hole, so the Game Boy
picture is fitted into the TV's tube instead of the whole window.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

+6
View File
@@ -0,0 +1,6 @@
overlays = 1
overlay0_overlay = img/tv-integer.png
overlay0_full_screen = true
overlay0_descs = 0
overlay0_viewport = "0.2335,0.0855,0.5335,0.830"
overlay0_viewport_fill = true
+17
View File
@@ -702,6 +702,23 @@ M.MT_MOON_B2F = {
return false
end,
talk = {
-- MtMoonB2FSuperNerdText: once beaten his line turns on the fossils
-- (scripts/MtMoonB2F.asm:187), which the header's flat `after` can't hold
TEXT_MTMOONB2F_SUPER_NERD = function(game, ow, npc, done)
if not superNerdBeaten(ow) then
engageSuperNerd(game, ow, done)
return
end
local TextBox = require("src.render.TextBox")
local t = game.data.text
local flags = game.save.flags
local line = (flags.EVENT_GOT_DOME_FOSSIL or flags.EVENT_GOT_HELIX_FOSSIL)
and (t._MtMoonB2FSuperNerdTheresAPokemonLabText
or "Far away, on\nCINNABAR ISLAND,\nthere's a POKéMON\nLAB.")
or (t._MtMoonB2fSuperNerdEachTakeOneText
or "We'll each take\none!\nNo being greedy!")
game.stack:push(TextBox.new(game, line, done))
end,
TEXT_MTMOONB2F_DOME_FOSSIL = mtMoonFossil(
"DOME_FOSSIL", "MTMOONB2F_HELIX_FOSSIL", "EVENT_GOT_DOME_FOSSIL"),
TEXT_MTMOONB2F_HELIX_FOSSIL = mtMoonFossil(
+30 -27
View File
@@ -118,33 +118,36 @@ M.ROUTE_15_GATE_2F = {
M.MT_MOON_POKECENTER = {
talk = {
TEXT_MTMOONPOKECENTER_MAGIKARP_SALESMAN = function(game, ow, npc, done)
local t = text(game)
if game.save.flags.EVENT_BOUGHT_MAGIKARP then
push(game, t._MtMoonPokecenterMagikarpSalesmanNoRefundsText
or "Well, I don't\ngive refunds!", done)
return
end
ask(game, t._MtMoonPokecenterMagikarpSalesmanOfferText
or "MAGIKARP! A\nsteal at ¥500!\nWant one?", function(yes)
if not yes then
push(game, t._MtMoonPokecenterMagikarpSalesmanNoText
or "No? I'm only\nselling today!", done)
return
end
if game.save.money < 500 then
push(game, t._MtMoonPokecenterMagikarpSalesmanNoMoneyText
or "You'll need more\nmoney than that!", done)
return
end
game.save.money = game.save.money - 500
game.save.flags.EVENT_BOUGHT_MAGIKARP = true
local Commands = require("src.script.Commands")
Commands.give_pokemon({ save = game.save, game = game, overworld = ow },
"MAGIKARP", 5)
push(game, t._GotMonText or "{PLAYER} got\n{RAM:wNameBuffer}!", done)
end)
end,
-- command rows, not a Lua handler: give_pokemon needs a runner to AskName (#1407)
TEXT_MTMOONPOKECENTER_MAGIKARP_SALESMAN = {
{ "check_flag", "EVENT_BOUGHT_MAGIKARP" },
{ "jump_if_true", "no_refunds" },
-- MONEY_BOX goes up between the offer and YesNoChoice -- MtMoonPokecenter.asm:31
{ "text_opts", { money = true } },
{ "ask", "_MtMoonPokecenterMagikarpSalesmanIGotADealText" },
{ "jump_if_false", "declined" },
{ "check_money", 500 },
{ "jump_if_false", "no_money" },
{ "give_pokemon", "MAGIKARP", 5 },
-- MtMoonPokecenter.asm:49 `jr nc, .done`: a refused gift is never charged
{ "jump_if_false", "box_full" },
{ "take_money", 500 },
{ "set_flag", "EVENT_BOUGHT_MAGIKARP" },
{ "text_sound", "Get_Item1" },
{ "show_text", "_GotMonText", { RAM = "MAGIKARP" } },
{ "jump", "end" },
{ "label", "box_full" },
{ "show_text", "_BoxIsFullText" },
{ "jump", "end" },
{ "label", "declined" },
{ "show_text", "_MtMoonPokecenterMagikarpSalesmanNoText" },
{ "jump", "end" },
{ "label", "no_money" },
{ "show_text", "_MtMoonPokecenterMagikarpSalesmanNoMoneyText" },
{ "jump", "end" },
{ "label", "no_refunds" },
{ "show_text", "_MtMoonPokecenterMagikarpSalesmanNoRefundsText" },
},
},
}
+2 -20
View File
@@ -11,28 +11,10 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
* **Persistent custom options** stored separately from game saves
* **Optional widescreen battle layout**
* **Mobile touch controls** with editable layouts, vibration, and orientation settings
* **Translation and custom font support**
* **Built-in save editor** for parties, boxes, items, events, maps, and Pokédex data
* **Tiled map editing tools** for mod authors
* **Touch skins** in RetroArch overlay format, with bezel art, per-button press states, and Super Game Boy borders
* **Pokédex diploma and printer image exports**
* **Community mod browser**
* **Soft reset button combination**
* **Keyboard and controller rebinding**
* **Mod profiles** with separate mod settings and save slots
* **Sandboxed mods**: an installed mod can read only its own folder and write only its own storage
* **Improved launcher and save editor UI**, including background downloads and update checks
* **Direct-launch options** for shortcuts, Steam entries, and handheld frontends
## Pokémon Gold (Gen 2)
## Gen 2 Specifics
* **COLOR, zoom, tilt, GBC FX, and quick save/load**
* **UI that stays fixed while the overworld zooms**
* **Border-block surrounds** for maps smaller than the screen
* **Gold-specific launcher options**
* **Optional widescreen battle layout**
* **Skippable trade animation** with B or START
* **QUIT and EXIT GAME** from the menus
* **Mod manager** with Gen 1 mod adapters, per-game targeting, and `modkit gen2check`
* **Followers** for mods, plus Gen 2-only registries and hooks
* **On-screen touch pad** and controller SELECT for registered items
* **Older mods keep loading** after the sandbox change, through per-mod compat stand-ins for the pre-sandbox globals
+179
View File
@@ -0,0 +1,179 @@
# Touch skins and the Skin Studio
A **skin** replaces the on-screen controls wholesale: a bezel image, a
control layout, and the rectangle the Game Boy screen is drawn into. Engine:
`src/core/TouchSkin.lua` (model, parsers, zip export), `src/core/TouchControls.lua`
(draw and input), `src/render/Renderer.lua` (the screen viewport),
`src/ui/SkinStudio.lua` (the desktop editor). Tests:
`tests/engine/touch_skin_test.lua`, `tests/engine/skin_studio_test.lua`,
`tests/engine/launcher_skins_tab.lua`.
Skins are picked in the launcher's **Skins** tab, which also imports them and
opens the studio. `options.touchControls.skin` holds the folder name.
## Formats
Two load. `skin.lua` wins when a folder has both.
**RetroArch overlay `.cfg`.** The libretro `common-overlays` collection loads
as-is. Supported keys:
| Key | Meaning |
| --- | --- |
| `overlays` | page count |
| `overlayN_name` | page name, the target of `next_target` |
| `overlayN_overlay` | bezel image |
| `overlayN_full_screen` | stretch the page to the window |
| `overlayN_rect` | page placement, default `0,0,1,1` |
| `overlayN_aspect_ratio` | fallback aspect when not full screen |
| `overlayN_range_mod`, `overlayN_alpha_mod` | desc defaults |
| `overlayN_viewport` | `x,y,w,h`, the screen cutout |
| `overlayN_viewport_fill` | parsed; the engine always fits, see below |
| `overlayN_descM` | `binds,x,y,shape,range_x,range_y` |
| `overlayN_descM_overlay` | control art |
| `overlayN_descM_next_target` | page to switch to |
| `overlayN_descM_range_mod`, `_alpha_mod` | per-control overrides |
| `overlayN_descM_reach_x/_y/_up/_down/_left/_right` | hitbox reach |
`x,y` is the centre and `range_x,range_y` are half extents, both normalized.
Hitboxes are `radial` or `rect`. Pipe-separated binds (`left|down`) are one
control that holds both. A `nul` desc is decoration: it draws and never
captures a touch.
Alpha follows RetroArch (`input_driver.c`, `input_overlay_post_poll`): every
image sits at the overlay opacity, and a pressed control's image swaps to
`opacity * alpha_mod`. So `alpha_mod` above 1 lights a control up and below 1
fades it out, and both directions read as a press animation.
**Native `skin.lua`.** This module's own model written back out: one Lua
table, no flat key space, and a separate `imagePressed` per control that a
`.cfg` cannot express. Loaded with an empty environment, so a skin authored by
a stranger cannot reach `love` or `io`. Sizes here are full width and height
rather than RetroArch's half extents, because that is what an editor's numeric
fields mean.
```lua
return {
name = "my_skin",
pages = {
{
name = "main",
image = "img/bezel.png",
fullScreen = true,
viewport = { x = 0.0, y = 0.0, w = 1.0, h = 0.5, fill = false },
controls = {
{ bind = "a", x = 0.87, y = 0.72, w = 0.18, h = 0.10,
shape = "radial", image = "img/a.png", imagePressed = "img/a_down.png" },
},
},
},
}
```
## Bindable actions
The eight Game Boy buttons: `a`, `b`, `start`, `select`, `up`, `down`,
`left`, `right`.
Engine hotkeys, handled in `Game:touchSkinHotkey`:
| Bind | Effect |
| --- | --- |
| `overlay_next`, `overlay_previous` | switch page, honouring `next_target` |
| `hold_fast_forward`, `fast_forward` | fast forward while held |
| `toggle_fast_forward` | step the speed option |
| `reset` | soft reset to the title |
| `menu_toggle` | open OPTIONS |
`screenshot`, `pause_toggle` and `exit_emulator` are recognised but have no
handler yet: a control bound to them draws and does nothing. Anything else,
`rewind` included, is not in the bind table at all, so the control falls back
to decoration and never captures a touch.
As an extension to the format, `key:<name>` presses any keyboard key, which is
how a skin button reaches a mod hotkey.
## The screen viewport
`overlayN_viewport` is the cutout the picture is fitted into. The Game Boy
screen keeps its whole-pixel scale and letterboxes inside that rect rather than
stretching to it, so a bezel gets an exact 160x144 picture; `viewport_fill` is
parsed but does not stretch. `overlayN_viewport_expand = true` is an extension
that lets a widescreen bezel take the filling survey-zoom world view instead.
A viewport also implies the faithful-ratio lock. Without it the world pass
expands to fill the cutout and you get more map instead of a Game Boy screen.
Border art often ships with a transparent hole and no `viewport` key. **Detect
screen from bezel** in the studio measures the hole out of the art's alpha
channel and writes the rect.
## Bezels versus pads
A skin whose active page binds nothing is a frame rather than a pad: a TV
surround, a handheld shell, a Super Game Boy border. Those draw on **desktop**
as well, where the touch overlay itself does not, and a gamepad does not hide
them. Anything that binds a button still follows the usual mobile /
`POKEPORT_TOUCH` rule.
## Installing
Drop a folder or a `.zip` into `skins/` in the save directory, or drop a zip on
the launcher window while the Skins tab is open. A zip is mounted in place, so
there is nothing to unpack. The folder needs one `skin.lua` or `.cfg`
(`overlay.cfg` is preferred when there are several) and the images it names.
Two ship bundled, both from libretro's `common-overlays` under CC-BY-4.0:
| Skin | Source | Shape |
| --- | --- | --- |
| `gb_anim` | `gamepads/gb_anim_portrait` | handheld shell, working buttons, two pages |
| `tv_crt` | `borders/tv-integer` | CRT television frame, no buttons |
Attribution lives in each folder's `README.md`. `tv_crt` is a photograph of a
real television: CC-BY-4.0 upstream, but treat it as a test asset rather than
shipping branding.
## The studio
Launcher, Skins tab, **Open Skin Studio**, or the gear on any skin row to open
that skin. Desktop only: the launcher does not offer it on Android or iOS,
because it wants a mouse, typed coordinates and room for an inspector.
**Canvas.** A mock device at a chosen preset, so a phone skin is authored at
phone proportions on a desktop monitor.
| Preset | Size |
| --- | --- |
| Phone portrait / landscape | 1080x1920, 1920x1080 |
| Tablet portrait / landscape | 1536x2048, 2048x1536 |
| Steam Deck | 1280x800 |
| Desktop 1080p | 1920x1080 |
| Ultrawide 21:9 | 2560x1080 |
| Super Game Boy border | 256x224 |
The Super Game Boy preset locks the viewport to the real screen window,
160x144 at (48,40), so an SGB border cannot be drawn out of register.
**Editing.** Click a control to select it, drag to move, eight handles to
resize. X / Y / W / H are in canvas pixels, so a control can be typed to the
coordinate its art was drawn at. Bind, hitbox shape, hit reach and idle and
pressed images are per control; the bezel, the pages and the screen cutout are
per page. The cutout is itself a draggable element with a 10:9 lock. Drop a PNG
or JPG on the window to import art into the skin.
**Testing.** **Test** makes the canvas live: clicking presses real Game Boy
buttons and the footer reports what is held. **Play** saves the skin, selects
it, and boots the game with it.
**Saving.** **Save** writes `skins/<name>/skin.lua` and copies every image the
skin names, so the folder stands alone. **Export** packs it as one zip
(`src/core/SkinZip.lua`, store-only) carrying the native `skin.lua`, the
images, and the original `.cfg` when it came from one. An exported skin drops
straight back into `skins/` and still opens in RetroArch.
## Not implemented
RetroArch's `analog_*`, `dpad_area`, `abxy_area` and `retrok_*` desc types.
Image assignment cycles through art already in the skin folder; there is no
file browser, so new art arrives by drag and drop.
+1
View File
@@ -120,6 +120,7 @@ bundled game, in that case.
## Known limitations
- **`love.run` persists across handoff.** By the time `chainload` runs, the
bundled `love.run` has already returned its stepper to LOVE; redefining the
global `love.run` from the payload's `main.lua` does not affect the loop
+55 -3
View File
@@ -36,7 +36,7 @@ do
end
end
local Game, EditorApp, Importer, TouchEditor
local Game, EditorApp, Importer, TouchEditor, Studio
-- #887: quit-to-launcher state, shared by love.load and love.quit (both need
-- it, so it is declared here rather than next to love.quit).
@@ -255,7 +255,43 @@ function closeTouchControlsEditor()
end
end
local function bootGame(version)
-- ------------------------------------------------------------ skin studio
local studioHost
local closeSkinStudio
local bootGame
local function openSkinStudio(version, skinId)
local SkinStudio = require("src.ui.SkinStudio")
if not SkinStudio.available_desktop() then return end
studioHost = Importer
if Importer and Importer.prepareOverlayHandoff then
Importer:prepareOverlayHandoff()
end
Importer = nil
Studio = SkinStudio
Studio.load({
version = version,
skinId = skinId,
onClose = function() closeSkinStudio() end,
onPlay = function(v)
closeSkinStudio()
Importer = nil
bootGame(v or version)
end,
})
end
function closeSkinStudio()
if Studio and Studio.unload then Studio.unload() end
Studio = nil
Importer = studioHost
studioHost = nil
if Importer and Importer.resumeAfterOverlay then
Importer:resumeAfterOverlay()
end
end
function bootGame(version)
-- The launcher hands us the chosen game (Red / Blue / Yellow / Gold);
-- scripted and headless runs fall back to POKEPORT_VERSION, then Red.
-- Set the active version and overlay its extracted cache BEFORE anything
@@ -456,6 +492,8 @@ function love.load(args)
forceImport = forceImport,
onEditSave = openEditor,
onEditTouchControls = openTouchControlsEditor,
onOpenSkinStudio = require("src.ui.SkinStudio").available_desktop()
and openSkinStudio or nil,
})
end
@@ -466,6 +504,7 @@ function love.update(dt)
NxDisplay.sync()
if editorMode then return EditorApp.update(dt) end
if TouchEditor then return TouchEditor.update(dt) end
if Studio then return Studio.update(dt) end
if Importer then return Importer:update(dt) end
-- Scripted runs (autopilot / POKEPORT_DRIVER) observe and act exactly
@@ -522,6 +561,12 @@ function love.draw()
HostDisplay.endFrame("touch_editor", TouchEditor)
return result
end
if Studio then
HostDisplay.beginFrame("skin_studio", Studio)
local result = Studio.draw()
HostDisplay.endFrame("skin_studio", Studio)
return result
end
if Importer then
GameViewport.reset()
HostDisplay.beginFrame("launcher", Importer)
@@ -555,12 +600,13 @@ end
function love.keypressed(key, scancode, isrepeat)
if editorMode then return EditorApp.keypressed(key) end
if TouchEditor then return TouchEditor.keypressed(key) end
if Studio then return Studio.keypressed(key) end
if Importer then return Importer:keypressed(key) end
Game:keypressed(key)
end
function love.keyreleased(key)
if editorMode or TouchEditor then return end
if editorMode or TouchEditor or Studio then return end
if Importer then return end
Game:keyreleased(key)
end
@@ -789,6 +835,7 @@ function love.wheelmoved(x, y)
return
end
if TouchEditor then return end
if Studio then return Studio.wheelmoved(x, y) end
if Importer then return end
Game:wheelmoved(x, y)
end
@@ -826,6 +873,7 @@ function love.mousepressed(x, y, button, istouch)
if love.system.getOS() == "Android" then return end
return TouchEditor.mousepressed(x, y, button)
end
if Studio then return Studio.mousepressed(x, y, button) end
if Importer then
-- love.touchpressed already forwards the primary touch into FlexLove for
-- scroll. LÖVE ALSO synthesizes a mouse press for that same touch; if both
@@ -860,6 +908,7 @@ function love.mousereleased(x, y, button, istouch)
if love.system.getOS() == "Android" then return end
return TouchEditor.mousereleased(x, y, button)
end
if Studio then return Studio.mousereleased(x, y, button) end
if Importer then return end
if editorMode and EditorApp.mousereleased then
return EditorApp.mousereleased(x, y, button)
@@ -877,6 +926,7 @@ function love.mousemoved(x, y, dx, dy, istouch)
if love.system.getOS() == "Android" then return end
return TouchEditor.mousemoved(x, y)
end
if Studio then return Studio.mousemoved(x, y) end
if editorMode or Importer then return end
if mouseTouch then
if Game and love.mouse.isDown(1) then Game:touchmoved("mouse", x, y) end
@@ -887,6 +937,7 @@ end
function love.textinput(text)
if TouchEditor then return end
if Studio then return Studio.textinput(text) end
if Importer then return Importer:textinput(text) end
if editorMode and EditorApp.textinput then
return EditorApp.textinput(text)
@@ -964,6 +1015,7 @@ function love.filedropped(file)
if editorMode and EditorApp and EditorApp.filedropped then
return EditorApp.filedropped(file)
end
if Studio then return Studio.filedropped(file) end
if Importer then Importer:filedropped(file) end
end
+78 -45
View File
@@ -24,6 +24,7 @@ local Runtime = require("src.mods.Runtime")
local BattleSafety = require("src.battle.BattleSafety")
local Screens = require("src.ui.Screens")
local Status = require("src.battle.Status")
local Theme = require("src.ui.Theme")
local Timing = require("src.core.Timing")
local TrainerAI = require("src.battle.TrainerAI")
local TurnOrder = require("src.battle.TurnOrder")
@@ -983,8 +984,8 @@ end
-- Message that opens YES/NO once typed out, keeping the text visible
-- underneath (pokered `done` + TWO_OPTION_MENU / TextBox opts.choice).
function BattleState:sayChoice(text, onChoose)
table.insert(self.queue, { text = text, choice = onChoose })
function BattleState:sayChoice(text, onChoose, opts)
table.insert(self.queue, { text = text, choice = onChoose, choiceOpts = opts })
end
function BattleState:act(fn)
@@ -1485,7 +1486,7 @@ function BattleState:updateQueue()
local fn = item.choice
battle.current = nil
fn(yes)
end))
end, item.choiceOpts))
return true
end
if item and item.auto then
@@ -1843,14 +1844,7 @@ function BattleState:enter()
self:say(self:sendOutText(self.player.name))
-- then the POOF plays and the mon appears with its cry
-- (SendOutMon: message -> AnimateSendingOutMon -> PlayCry)
table.insert(self.queue, { anim = "POOF_ANIM", attackerIsPlayer = false })
self:act(function()
self.sendingOut = false
-- SendOutMon (core.asm:1757-1762): after the poof the mon grows
-- out of the ball (AnimateSendingOutMon at hlcoord 4,11)
self:startGrowIn(self.player)
self:waitSfxNext(self:playEntranceCry(self.player))
end)
self:queueSendOutAnim(true)
self:markParticipant()
end
self.phase = "messages"
@@ -2680,13 +2674,7 @@ function BattleState:resolveSwitch(newMon)
sendOutMonCursors(self)
self.sendingOut = true
self:sayNext(self:sendOutText(self.player.name))
self:animNext("POOF_ANIM", false)
self:actNext(function()
self.sendingOut = false
-- SendOutMon (core.asm:1757-1762): poof, then the grow-in
self:startGrowIn(self.player)
self:waitSfxNext(self:playEntranceCry(self.player))
end)
self:queueSendOutAnim(false)
end)
self:act(function()
self:executeAction(self.enemy, self.player, self:enemyAction())
@@ -3231,6 +3219,50 @@ function BattleState:startGrowIn(battler)
table.insert(self.queue, self.nextInsert, { wait = 12 })
end
-- SendOutMon branches on IsThisPartyMonStarterPikachu before the animation:
-- the starter gets no ball and no grow-in -- pokeyellow core.asm:1798-1819
function BattleState:starterPikachuSendOut()
if not require("src.core.GameVersion").isYellow() then return false end
local mon = self.player and self.player.mon
return require("src.world.PikachuFollower")
.isStarterPikachu(self.game.save, mon)
end
-- StarterPikachuBattleEntranceAnimation: the back pic walks in from hlcoord
-- 0,5, one column every 2 frames -- engine/battle/pikachu_entrance_anim.asm:1
function BattleState:startPikachuEntrance()
self:slidePic("playerMon", -56, 0, 8, 2)
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert, { wait = 16 })
self:actNext(function()
self:slidePic("playerMon")
self:waitSfxNext(self:playEntranceCry(self.player))
end)
end
-- Player send-out tail: POOF_ANIM + AnimateSendingOutMon (core.asm:1757-1762),
-- or the starter Pikachu entrance instead (pokeyellow core.asm:1798-1819)
function BattleState:queueSendOutAnim(append)
local pikachu = self:starterPikachuSendOut()
if not pikachu then
if append then
table.insert(self.queue, { anim = "POOF_ANIM", attackerIsPlayer = false })
else
self:animNext("POOF_ANIM", false)
end
end
local fn = function()
self.sendingOut = false
if pikachu then
self:startPikachuEntrance()
else
self:startGrowIn(self.player)
self:waitSfxNext(self:playEntranceCry(self.player))
end
end
if append then self:act(fn) else self:actNext(fn) end
end
-- Should the low-health alarm sound this frame? pokered keys it off
-- the drawn bar color: DrawPlayerHUDAndHPBar (core.asm:1846-1875) sets
-- wLowHealthAlarm bit 7 when GetHealthBarColor says the player bar is
@@ -3289,17 +3321,18 @@ end
-- _ScrollTrainerPicAfterBattle (engine/battle/scroll_draw_trainer_pic.asm)
-- brings the beaten foe back in from the right one column every 4 frames.
-- picOff holds the live programs by slot -- "foe" = the enemy trainer pic,
-- "back" = the player's back pic -- as a screen-pixel x offset stepped
-- toward `to`; updateFx advances them, drawPicsLayer adds them, and the
-- queue rows that start them park a { wait } of the matching length. Call
-- with no target to clear a slot (#317, #282).
function BattleState:slidePic(slot, from, to, step)
-- "back" = the player's back pic, "playerMon" = the player's mon back pic --
-- as a screen-pixel x offset stepped toward `to`; updateFx advances them,
-- drawPicsLayer adds them, and the queue rows that start them park a
-- { wait } of the matching length. Call with no target to clear
-- a slot (#317, #282).
function BattleState:slidePic(slot, from, to, step, hold)
self.picOff = self.picOff or {}
if to == nil then
self.picOff[slot] = nil
return
end
self.picOff[slot] = { x = from or 0, to = to, step = step or 4 }
self.picOff[slot] = { x = from or 0, to = to, step = step or 4, hold = hold }
end
-- the live x offset for a pic slot, 0 when nothing is sliding
@@ -3317,10 +3350,18 @@ function BattleState:updateFx()
-- the battle slot) until its owner clears the slot
if self.picOff then
for _, p in pairs(self.picOff) do
if p.x < p.to then
p.x = math.min(p.to, p.x + p.step)
elseif p.x > p.to then
p.x = math.max(p.to, p.x - p.step)
local move = true
if p.hold then
p.held = (p.held or 0) + 1
move = p.held >= p.hold
if move then p.held = 0 end
end
if move then
if p.x < p.to then
p.x = math.min(p.to, p.x + p.step)
elseif p.x > p.to then
p.x = math.max(p.to, p.x - p.step)
end
end
end
end
@@ -4070,8 +4111,8 @@ function BattleState:awardExp()
end
local function applyShare(mon, split, announce)
local playerId = self.game.save.player and self.game.save.player.id
local traded = mon.otId ~= nil and playerId ~= nil
and mon.otId ~= playerId or mon.traded == true and mon.otId == nil
local traded = mon.traded == true
or (mon.otId ~= nil and playerId ~= nil and mon.otId ~= playerId)
local levels, gained = Experience.apply(self.data, mon, self.enemy.def,
self.enemy.mon.level, self.kind == "trainer",
split, traded)
@@ -4231,7 +4272,7 @@ function BattleState:enemyMonFainted()
end
end,
})
end)
end, { box = Theme.trainerSwitchBox })
end
self:act(function()
local previous = self.enemy
@@ -4295,12 +4336,7 @@ function BattleState:enemyMonFainted()
sendOutMonCursors(self)
self.sendingOut = true
self:sayNext(self:sendOutText(self.player.name))
self:animNext("POOF_ANIM", false)
self:actNext(function()
self.sendingOut = false
self:startGrowIn(self.player)
self:waitSfxNext(self:playEntranceCry(self.player))
end)
self:queueSendOutAnim(false)
end)
return
end
@@ -4491,13 +4527,7 @@ function BattleState:openReplacementMenu()
sendOutMonCursors(self)
self.sendingOut = true
self:sayNext(self:sendOutText(self.player.name))
self:animNext("POOF_ANIM", false)
self:actNext(function()
self.sendingOut = false
-- SendOutMon (core.asm:1757-1762): poof, then the grow-in
self:startGrowIn(self.player)
self:waitSfxNext(self:playEntranceCry(self.player))
end)
self:queueSendOutAnim(false)
end,
})
end)
@@ -5749,7 +5779,10 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip)
else
local dx, dy = BattleState.backPlacement(img:getWidth(),
img:getHeight(), pad, padL, s)
self:drawBattlerPic(self.player, dx + sx, dy + sy, s)
-- picOffset: StarterPikachuBattleEntranceAnimation walking the pic in
-- from the left -- engine/battle/pikachu_entrance_anim.asm:1
self:drawBattlerPic(self.player, dx + sx + self:picOffset("playerMon"),
dy + sy, s)
end
end
if clipped then
+3 -2
View File
@@ -243,8 +243,9 @@ end
C.incbgeffect = function(self, row) self.bg:incEffect(row[2]) end
C.battlergfx_1row = function(self) self:loadBattlerGfx(1) end
C.battlergfx_2row = function(self) self:loadBattlerGfx(2) end
-- anim_commands.asm:317: $d9 (anim_battlergfx_2row) dispatches to _1Row
C.battlergfx_1row = function(self) self:loadBattlerGfx(2) end
C.battlergfx_2row = function(self) self:loadBattlerGfx(1) end
-- GetPokeBallWobble's answer, which the ball's own script then branches on.
C.checkpokeball = function(self)
+53 -18
View File
@@ -1868,7 +1868,11 @@ function Battle:useMove(attacker, defender, moveId)
if status and (def.power or 0) == 0 then
-- A refused primary status is a failed move (effect_commands.asm:3748,
-- :6656); a refused secondary already animated and stays unmarked (:3752).
if Battle.AI_FAIL_STATUSES[status]
if self:statusRefusedByType(defender, def.type, status) then
self:markMissed()
self:emit({ kind = "message",
text = "It doesn't affect " .. self:monName(defender) .. "..." })
elseif Battle.AI_FAIL_STATUSES[status]
and self:aiRandomFail(attacker, defender) then
self:markMissed()
self:emit({ kind = "message", text = "But it failed!" })
@@ -1880,7 +1884,8 @@ function Battle:useMove(attacker, defender, moveId)
and record.status or nil
-- engine/battle/effect_commands.asm:6325
if secondary and (defender.hp or 0) > 0
and not self:safeguarded(defender) then
and not self:safeguarded(defender)
and not self:statusRefusedByType(defender, def.type, secondary) then
local chance = def.effectChance or 0
if chance > 0 and rand(self.random, 100) < chance then
self:applyStatus(defender, secondary, attacker)
@@ -2941,6 +2946,27 @@ end
-- `source` is the battler that inflicted it, carried only so
-- battle.status_inflicted can name it the way Gen 1's does.
-- BattleCommand_Paralyze and BattleCommand_Poison refuse on a zero matchup,
-- and the poison pair also refuses a POISON-type target: effect_commands.asm
-- :5788 (paralyze), :3671 (poison), :3646 / :4019 (the secondary arms).
-- Sleep, confusion and stat changes are deliberately not gated.
function Battle:statusRefusedByType(defender, moveType, status)
if not (status == "paralyze" or status == "poison" or status == "toxic") then
return false
end
local types = (self:speciesDef(defender) or {}).types or defender.types or {}
if moveType then
local matchups = self.data.type_chart and self.data.type_chart.matchups
if Damage.typeMultiplier(moveType, types, matchups) == 0 then return true end
end
if status == "poison" or status == "toxic" then
for _, t in ipairs(types) do
if t == "POISON" then return true end
end
end
return false
end
function Battle:applyStatus(mon, status, source)
if (mon.hp or 0) <= 0 then return false end
-- Confusion is SUBSTATUS_CONFUSED on the cart, not a status byte: it lives
@@ -3202,6 +3228,7 @@ end
-- player's own.
function Battle:isOutsider(mon)
local playerId = self.save and self.save.player and self.save.player.id
if mon.traded == true then return true end
if mon.otId == nil or playerId == nil then return false end
return mon.otId ~= playerId
end
@@ -3363,25 +3390,33 @@ function Battle:awardExperience(loser)
-- has no EXP.ALL (the EXP.SHARE pass below is its replacement), so it is
-- accepted and ignored rather than changing what is printed. `recipients`,
-- `holders` and `halved` are the Gen 2 additions.
if not Runtime.wantsHook("battle.exp_award") then return vanillaAward() end
local alive = {}
for _, index in ipairs(participants) do
local mon = self.party[index]
if mon and (mon.hp or 0) > 0 then alive[#alive + 1] = mon end
end
local function applyShare(mon, split)
for index, candidate in ipairs(self.party) do
if candidate == mon then
return self:giveExperiencePass(loser, def, { index },
math.max(1, split or 1), halved)
if Runtime.wantsHook("battle.exp_award") then
local alive = {}
for _, index in ipairs(participants) do
local mon = self.party[index]
if mon and (mon.hp or 0) > 0 then alive[#alive + 1] = mon end
end
local function applyShare(mon, split)
for index, candidate in ipairs(self.party) do
if candidate == mon then
return self:giveExperiencePass(loser, def, { index },
math.max(1, split or 1), halved)
end
end
end
Runtime.call("battle.exp_award", vanillaAward, {
battle = self, participants = #participants, alive = alive,
applyShare = applyShare, recipients = participants, holders = holders,
halved = halved, loser = loser,
})
else
vanillaAward()
end
Runtime.call("battle.exp_award", vanillaAward, {
battle = self, participants = #participants, alive = alive,
applyShare = applyShare, recipients = participants, holders = holders,
halved = halved, loser = loser,
})
-- GiveExperiencePoints .done falls through ResetBattleParticipants into
-- AddBattleParticipant (engine/battle/core.asm:7116 and :3033).
self.participants = {}
if self.playerIndex then self.participants[self.playerIndex] = true end
end
-- The answer to a `choose-forget`: drop the move in `slot` and put the
+7 -8
View File
@@ -347,14 +347,13 @@ function Catching.attempt(opts)
-- that edits o.catchRate or o.status changes the roll exactly as it does
-- on Red, and one that returns `caught, rate` replaces it outright.
--
-- `mon` and `def` are whatever the catch site supplied. Gold's battle
-- screen (src/ui/gen2/BattleState.lua) hands this module a FLAT opts table
-- -- hp, maxHp, catchRate, status, species -- rather than the mon and its
-- record, so both are nil there until it passes them; nil, not a stand-in
-- a mod would read as the real mon. Passing `battle = self.battle` and
-- `mon = enemy` with them is what puts the capture tail below on the real
-- catch: the Transform reload and the battle.catch_exp hook both hang off
-- the battle, and neither can be reached from a flat table.
-- `mon` and `def` are whatever the catch site supplied, and nil rather
-- than a stand-in a mod would read as the real mon when it supplied
-- neither. Gold's battle screen (src/ui/gen2/BattleState.lua) passes them
-- with `battle` alongside the flat hp/maxHp/catchRate/status fields, which
-- is what puts the capture tail below on the real catch: the Transform
-- reload and the battle.catch_exp hook both hang off the battle, and
-- neither can be reached from a flat table.
caught, rate = Runtime.call("catch.rate", function(_, _, _, o)
return Catching.vanillaAttempt(o)
end, opts.ball or "POKE_BALL", opts.mon, opts.def, opts)
+2 -1
View File
@@ -388,7 +388,8 @@ function Mon.stampOT(save, mon)
mon.ot = mon.ot or player.name
-- NpcTrade.lua:150: `ot` is what Breeding reads, `otName` what the summary prints.
mon.otName = mon.otName or mon.ot
mon.otId = mon.otId or player.id
-- engine/battle/experience.asm:69: a traded mon keeps its own OT id
if not mon.traded then mon.otId = mon.otId or player.id end
return mon
end
+38
View File
@@ -59,6 +59,9 @@ function Game:load()
self.touchControls = TouchControls
TouchControls:init()
TouchControls:setHotkeyHandler(function(action, pressed)
self:touchSkinHotkey(action, pressed)
end)
self.renderer = Renderer
Renderer:init()
@@ -185,6 +188,40 @@ function Game:returnToTitle()
self.stack:push(self:makeTitleState())
end
Game.SKIN_FAST_FORWARD = 4
function Game:touchSkinHotkey(action, pressed)
if action == "fast_forward_hold" then
if pressed then
self.skinSpeedSaved = self.speedOverride
self.speedOverride = Game.SKIN_FAST_FORWARD
else
self.speedOverride = self.skinSpeedSaved
self.skinSpeedSaved = nil
end
elseif action == "fast_forward_toggle" then
if pressed then self:_cycleSpeed(1) end
elseif action == "soft_reset" then
if pressed then
Input:reset()
TouchControls:reset()
self:returnToTitle()
end
elseif action == "menu" then
if pressed then
local Screens = require("src.ui.Screens")
local top = self.stack and self.stack:top()
if top and not top.isTouchSkinMenu then
local state = Screens.build(self, "OptionsMenu")
if state then
state.isTouchSkinMenu = true
self.stack:push(state)
end
end
end
end
end
function Game:step(dt)
-- Tool mods (autoplay, accessibility drivers, input visualizers) act on
-- the same fixed-step boundary as a physical controller. Run them before
@@ -1196,6 +1233,7 @@ function Game:restoreSave(loaded, recovered, opts)
self:applyOptions(loaded.options)
-- saves from before OT/ID stamping: backfill with the player's (after
-- the scrub, so every mon the stamp loop sees is known)
SaveData.repairTradedOtIds(loaded)
local stamp = require("src.battle.BattleState").stampOT
for _, mon in ipairs(loaded.party or {}) do stamp(loaded, mon) end
for _, box in ipairs(loaded.boxes or {}) do
+3
View File
@@ -939,6 +939,8 @@ function Game2:load()
self.data.gen2Maps = loadGenerated("data/generated/maps.lua")
self.data.gen2Tilesets = loadGenerated("data/generated/tilesets.lua")
self.data.gen2Roofs = loadGenerated("data/generated/roofs.lua")
-- engine/events/magnet_train.asm:165 DrawMagnetTrain
self.data.gen2Field = loadGenerated("data/generated/field.lua")
self.data.gen2Marts = loadGenerated("data/generated/marts.lua")
self.data.gen2Scripts = loadGenerated("data/generated/scripts.lua")
self.data.gen2StdScripts = loadGenerated("data/generated/std_scripts.lua")
@@ -1965,6 +1967,7 @@ function Game2:applyOptions()
haptics = options.haptics,
})
require("src.core.VideoMode").applyOptions(options)
require("src.core.FrameCap").applyOptions(options)
local GBCFX = require("src.render.GBCFX")
if GBCFX.applyOptions(options) and self.save then
-- applyOptions returns true when it had to clear an unsupported level.
+17 -5
View File
@@ -6,11 +6,14 @@ local HostShell = {}
-- spawn tries to link against the libraries we're shipping instead of the
-- system ones. We want to unset the var so that any system tools can find
-- their proper libraries. Only needed when running in an AppImage.
-- LD_PRELOAD is Steam's overlay (#1470): its 32-bit half cannot load into a
-- 64-bit child, so ld.so prints an error into that child's output.
function HostShell.envPrefix()
if os.getenv("APPIMAGE") then
return "env -u LD_LIBRARY_PATH "
end
return ""
local unset = ""
if os.getenv("APPIMAGE") then unset = unset .. "-u LD_LIBRARY_PATH " end
if os.getenv("LD_PRELOAD") then unset = unset .. "-u LD_PRELOAD " end
if unset == "" then return "" end
return "env " .. unset
end
-- Windows: every host tool we shell out to (curl for the update and mod-index
@@ -222,11 +225,20 @@ end
local HTTP_MARK = "\n__gen1recomp_http__"
local HTTP_MARK_FMT = "\\n__gen1recomp_http__%{http_code}"
local function stripLoaderNoise(out)
while out:find("^ERROR: ld%.so:") do
local nl = out:find("\n", 1, true)
if not nl then return "" end
out = out:sub(nl + 1)
end
return out
end
-- Split a curl pipe's output into (body, status, noise). `status` is nil
-- when curl never got far enough to have one (DNS failure, no route, a
-- timeout), in which case `noise` carries curl's own complaint.
local function splitCurlOutput(out)
out = tostring(out or "")
out = stripLoaderNoise(tostring(out or ""))
local at = nil
local from = 1
while true do
+23 -4
View File
@@ -666,11 +666,10 @@ function SaveData.setModEnabled(options, id, enabled, version)
options.modsByVersion = options.modsByVersion or {}
local bucket = options.modsByVersion[version] or {}
options.modsByVersion[version] = bucket
-- no shared flag reads as enabled, the same default the loader applies to a
-- missing entry, so a fresh install never fills the overlay with agreement
-- with no shared flag the default is the caller's (experimental mods read as
-- disabled), so the answer is stored outright rather than judged against one
local shared = options.mods and options.mods[id]
if type(shared) ~= "boolean" then shared = true end
if shared == enabled then
if type(shared) == "boolean" and shared == enabled then
bucket[id] = nil
else
bucket[id] = enabled
@@ -1924,6 +1923,26 @@ function SaveData.applyPostGameHome(save, boot)
return heal
end
-- 0.1.82-0.1.9x loads stamped the player's own id onto traded mons and saved
-- it, which reads back as home-caught. A caught mon can never carry
-- traded=true, so clearing that pair is safe on every load. #1461
function SaveData.repairTradedOtIds(save)
local playerId = save and save.player and save.player.id
if playerId == nil then return 0 end
local fixed = 0
local function scrub(mon)
if mon and mon.traded == true and mon.otId == playerId then
mon.otId = nil
fixed = fixed + 1
end
end
for _, mon in ipairs(save.party or {}) do scrub(mon) end
for _, box in ipairs(save.boxes or {}) do
for _, mon in ipairs(box) do scrub(mon) end
end
return fixed
end
-- Softlocked 0.1.11 saves: still standing in HALL_OF_FAME after credits,
-- with lastOutdoor on Indigo. One-shot rescue on CONTINUE.
function SaveData.needsPostGameRescue(save)
+92
View File
@@ -0,0 +1,92 @@
local SkinZip = {}
local bxor
do
local ok, bit = pcall(require, "bit")
if ok and bit and bit.bxor then
bxor = function(a, b) return bit.bxor(a, b) % 0x100000000 end
else
local byteXor = {}
local function xor8(a, b)
local key = a * 256 + b
local memo = byteXor[key]
if memo then return memo end
local r, bitv = 0, 1
local x, y = a, b
for _ = 1, 8 do
if (x % 2) ~= (y % 2) then r = r + bitv end
x, y, bitv = math.floor(x / 2), math.floor(y / 2), bitv * 2
end
byteXor[key] = r
return r
end
bxor = function(a, b)
local r, mul = 0, 1
for _ = 1, 4 do
r = r + xor8(a % 256, b % 256) * mul
a, b, mul = math.floor(a / 256), math.floor(b / 256), mul * 256
end
return r
end
end
end
local crcTable
local function crc32(s)
if not crcTable then
crcTable = {}
for i = 0, 255 do
local c = i
for _ = 1, 8 do
if c % 2 == 1 then
c = bxor(math.floor(c / 2), 0xEDB88320)
else
c = math.floor(c / 2)
end
end
crcTable[i] = c
end
end
local crc = 0xFFFFFFFF
for i = 1, #s do
crc = bxor(crcTable[bxor(crc, s:byte(i)) % 256], math.floor(crc / 256))
end
return bxor(crc, 0xFFFFFFFF) % 0x100000000
end
local function le16(n)
n = math.floor(n) % 0x10000
return string.char(n % 256, math.floor(n / 256) % 256)
end
local function le32(n)
n = math.floor(n) % 0x100000000
return string.char(n % 256, math.floor(n / 256) % 256,
math.floor(n / 65536) % 256, math.floor(n / 16777216) % 256)
end
function SkinZip.encode(entries)
local out, central, offset = {}, {}, 0
for _, entry in ipairs(entries) do
local name, data = entry.name, entry.data or ""
local crc, size = crc32(data), #data
local local_ = "PK\3\4" .. le16(20) .. le16(0) .. le16(0)
.. le16(0) .. le16(0)
.. le32(crc) .. le32(size) .. le32(size)
.. le16(#name) .. le16(0) .. name
out[#out + 1] = local_
out[#out + 1] = data
central[#central + 1] = "PK\1\2" .. le16(20) .. le16(20) .. le16(0) .. le16(0)
.. le16(0) .. le16(0)
.. le32(crc) .. le32(size) .. le32(size)
.. le16(#name) .. le16(0) .. le16(0) .. le16(0) .. le16(0)
.. le32(0) .. le32(offset) .. name
offset = offset + #local_ + #data
end
local dir = table.concat(central)
return table.concat(out) .. dir
.. "PK\5\6" .. le16(0) .. le16(0) .. le16(#entries) .. le16(#entries)
.. le32(#dir) .. le32(offset) .. le16(0)
end
return SkinZip
+219 -7
View File
@@ -39,6 +39,7 @@
local Input = require("src.core.Input")
local SafeArea = require("src.core.SafeArea")
local TouchSkin = require("src.core.TouchSkin")
local TouchControls = {}
@@ -189,6 +190,7 @@ function TouchControls.normalizeConfig(tc)
-- no caller ever has to nil-check a bucket's scale
if type(tc) ~= "table" then tc = {} end
if tc.enabled == false then out.enabled = false end
if type(tc.skin) == "string" and tc.skin ~= "" then out.skin = tc.skin end
local saved = type(tc.layouts) == "table" and tc.layouts or nil
for _, o in ipairs(ORIENTATIONS) do
local b = saved and saved[o]
@@ -241,6 +243,7 @@ end
function TouchControls:init()
self.active = wantsOverlay()
TouchSkin.setOverlayLive(self.active)
self.enabled = true
-- vibration level for presses (#806); applyOptions overwrites it from
-- options.haptics, this is the value a harness that never applies options
@@ -261,6 +264,10 @@ function TouchControls:init()
self.held = {}
self.dpadTouch = nil
self.layoutW, self.layoutH = nil, nil
self.skinId = nil
self.skinError = nil
self.hotkeysHeld = {}
TouchSkin.setActive(nil)
self.img = nil
-- Images load whenever the platform wants the overlay OR the launcher
-- editor forces a preview (desktop testing of the editor).
@@ -285,6 +292,8 @@ function TouchControls:applyOptions(opts)
-- haptics is a plain top-level option, not part of the layout config the
-- launcher editor round-trips through config() (#806)
self.haptics = TouchControls.normalizeHaptics(opts and opts.haptics)
TouchSkin.setOverlayLive(self.active)
self:selectSkin(cfg.skin)
self.layouts = cfg.layouts
self.layoutW, self.layoutH = nil, nil
self.layoutOx, self.layoutOy = nil, nil
@@ -300,7 +309,7 @@ end
-- Snapshot for the editor's save path: enabled plus both orientation
-- buckets, matching what options.lua stores (#633).
function TouchControls:config()
local out = { enabled = self.enabled ~= false, layouts = {} }
local out = { enabled = self.enabled ~= false, skin = self.skinId, layouts = {} }
for _, o in ipairs(ORIENTATIONS) do
local b = self.layouts and self.layouts[o] or nil
out.layouts[o] = {
@@ -323,11 +332,56 @@ function TouchControls:setPreview(on)
end
function TouchControls:visible()
if self.preview then return self.img ~= nil end
return self.active and self.enabled ~= false and self.img ~= nil
and not self.controllerHidden
local art = TouchSkin.active ~= nil or self.img ~= nil
if self.preview then return art end
if self.enabled == false or not art then return false end
if TouchSkin.active and TouchSkin.decorativeOnly() then return true end
return self.active and not self.controllerHidden
end
local function surfaceRect()
local r = TouchSkin.surfaceRect
if r then return r.w, r.h, r.x, r.y end
if love and love.graphics and love.graphics.getDimensions then
local w, h = love.graphics.getDimensions()
return w, h, 0, 0
end
return 0, 0, 0, 0
end
TouchControls.surfaceRect = surfaceRect
function TouchControls:selectSkin(id)
if id == self.skinId and TouchSkin.active then return TouchSkin.active end
self:reset()
self.skinError = nil
if not id or id == "" then
self.skinId = nil
TouchSkin.setActive(nil)
return nil
end
local skin, err = TouchSkin.select(id)
if not skin then
self.skinId = nil
self.skinError = err
TouchSkin.setActive(nil)
return nil, err
end
self.skinId = id
return skin
end
function TouchControls:skin()
if not self:visible() then return nil end
return TouchSkin.active
end
function TouchControls:setHotkeyHandler(fn)
self.hotkeyHandler = type(fn) == "function" and fn or nil
end
local skinHitSet, applySkinSet
-- Keep a control fully inside the usable rect [x0,y0]..[x1,y1].
local function clampZone(zone, x0, y0, x1, y1)
local half = zone.w * 0.5
@@ -451,6 +505,15 @@ end
-- Which control (if any) contains (x, y). Prefer face buttons over the
-- d-pad when they overlap, matching touchpressed's order.
function TouchControls:hitTest(x, y)
local page = TouchSkin.page()
if page then
local ww, wh, ox, oy = surfaceRect()
for i = #page.controls, 1, -1 do
local ctl = page.controls[i]
if TouchSkin.hits(page, ctl, ww, wh, x, y, ox, oy) then return ctl.spec, ctl end
end
return nil
end
local L = self:layout()
for _, btn in ipairs(BUTTONS) do
if inCircle(L[btn], x, y, SLOP[btn]) then return btn end
@@ -505,6 +568,86 @@ local function setDpad(self, touch, dir)
if dir then pressBtn(self, dir) end
end
local function pressKey(key, down)
local fn = down and love.keypressed or love.keyreleased
if type(fn) == "function" then pcall(fn, key, key, false) end
end
local function fireHotkey(self, action, pressed, ctl)
if action == "overlay_next" then
if pressed then TouchSkin.nextPage(ctl and ctl.nextTarget) end
return pressed
end
if action == "overlay_prev" then
if pressed then TouchSkin.setPage(TouchSkin.pageIndex - 1) end
return pressed
end
self.hotkeysHeld = self.hotkeysHeld or {}
local n = (self.hotkeysHeld[action] or 0) + (pressed and 1 or -1)
if n < 0 then n = 0 end
self.hotkeysHeld[action] = n > 0 and n or nil
local edge = (pressed and n == 1) or (not pressed and n == 0)
if edge and self.hotkeyHandler then
pcall(self.hotkeyHandler, action, pressed)
end
return false
end
local function enterControl(self, ctl)
local buzzed = false
for _, btn in ipairs(ctl.buttons) do
if not self.held[btn] then buzzed = true end
pressBtn(self, btn)
end
for _, key in ipairs(ctl.keys) do pressKey(key, true) end
local switched = false
for _, action in ipairs(ctl.hotkeys) do
if fireHotkey(self, action, true, ctl) then switched = true end
end
if not buzzed and (ctl.keys[1] or ctl.hotkeys[1]) then
TouchControls.buzz(self.haptics)
end
return switched
end
local function exitControl(self, ctl)
for _, btn in ipairs(ctl.buttons) do releaseBtn(self, btn) end
for _, key in ipairs(ctl.keys) do pressKey(key, false) end
for _, action in ipairs(ctl.hotkeys) do fireHotkey(self, action, false, ctl) end
end
function skinHitSet(self, x, y)
local page = TouchSkin.page()
if not page then return nil end
local ww, wh, ox, oy = surfaceRect()
local set = nil
for _, ctl in ipairs(page.controls) do
if not ctl.decorative and TouchSkin.hits(page, ctl, ww, wh, x, y, ox, oy) then
set = set or {}
set[ctl] = true
end
end
return set
end
function applySkinSet(self, touch, set)
local prev = touch.set or {}
local switched = false
for ctl in pairs(prev) do
if not (set and set[ctl]) then exitControl(self, ctl) end
end
for ctl in pairs(set or {}) do
if not prev[ctl] then
if enterControl(self, ctl) then switched = true end
end
end
touch.set = set
if switched then
for ctl in pairs(touch.set or {}) do exitControl(self, ctl) end
touch.set = nil
end
end
-- Returns true when this touch was captured by a virtual control -- the
-- pad's first refusal on the gameplay pointer seam (#807). Capture is
-- decided here, at press, and rides self.touches[id] for the touch's
@@ -513,13 +656,22 @@ end
function TouchControls:touchpressed(id, x, y)
-- preview mode is layout-edit only: never press GB buttons
if self.preview then return end
if not (self.active and self.enabled ~= false and self.img) then return end
if not (self.active and self.enabled ~= false
and (TouchSkin.active or self.img)) then return end
-- a controller hid the overlay; the first touch only brings it back
-- (uncaptured: it began on no control, so mods may still see it)
if self.controllerHidden then
self.controllerHidden = false
return
end
if TouchSkin.active then
local set = skinHitSet(self, x, y)
if not set then return end
local touch = { control = "skin" }
self.touches[id] = touch
applySkinSet(self, touch, set)
return true
end
local L = self:layout()
for _, btn in ipairs(BUTTONS) do
if inCircle(L[btn], x, y, SLOP[btn]) then
@@ -544,9 +696,14 @@ end
function TouchControls:touchmoved(id, x, y)
if self.preview then return end
local touch = self.touches[id]
if not touch then return end
if touch.control == "skin" then
applySkinSet(self, touch, skinHitSet(self, x, y))
return
end
-- only the d-pad tracks movement (slide between directions without
-- lifting); buttons hold until release wherever the finger wanders
if not touch or touch.control ~= "dpad" then return end
if touch.control ~= "dpad" then return end
setDpad(self, touch, dpadDir(self:layout().dpad, x, y))
end
@@ -555,7 +712,9 @@ function TouchControls:touchreleased(id, x, y)
local touch = self.touches[id]
if not touch then return end
self.touches[id] = nil
if touch.control == "dpad" then
if touch.control == "skin" then
applySkinSet(self, touch, nil)
elseif touch.control == "dpad" then
setDpad(self, touch, nil)
self.dpadTouch = nil
else
@@ -568,6 +727,15 @@ end
-- touchreleased and would strand its button held forever. Called from
-- Game alongside Input:reset() on focus/visibility loss.
function TouchControls:reset()
for _, touch in pairs(self.touches or {}) do
if touch.control == "skin" and touch.set then
for ctl in pairs(touch.set) do exitControl(self, ctl) end
end
end
for action in pairs(self.hotkeysHeld or {}) do
if self.hotkeyHandler then pcall(self.hotkeyHandler, action, false) end
end
self.hotkeysHeld = {}
for btn in pairs(self.held or {}) do
Input:overlayReleased(btn)
end
@@ -608,12 +776,56 @@ local function drawIcon(img, zone, pressed, alphaMul)
zone.cy - img:getHeight() * scale / 2, 0, scale, scale)
end
local function drawStretched(img, x, y, w, h, alpha)
if not img or alpha <= 0 then return end
local iw, ih = img:getWidth(), img:getHeight()
if iw <= 0 or ih <= 0 then return end
love.graphics.setColor(1, 1, 1, math.min(1, alpha))
love.graphics.draw(img, x, y, 0, w / iw, h / ih)
end
function TouchControls:drawSkin(alphaMul)
local page = TouchSkin.page()
if not page then return false end
local ww, wh, sox, soy = surfaceRect()
local bx, by, bw, bh = TouchSkin.pageBox(page, ww, wh, sox, soy)
local opacity = (self.skinOpacity or 1) * alphaMul
love.graphics.push("all")
love.graphics.origin()
drawStretched(page.image, bx, by, bw, bh, opacity)
local pressed = {}
for _, touch in pairs(self.touches or {}) do
for ctl in pairs(touch.set or {}) do pressed[ctl] = true end
end
for _, ctl in ipairs(page.controls) do
local down = pressed[ctl] == true
local img = (down and ctl.pressedImage) or ctl.image
if img then
local cx, cy, halfW, halfH =
TouchSkin.controlGeometry(page, ctl, ww, wh, sox, soy)
local alpha = opacity
if down and not ctl.pressedImage then alpha = opacity * ctl.alphaMod end
drawStretched(img, cx - halfW, cy - halfH, halfW * 2, halfH * 2, alpha)
end
end
love.graphics.pop()
return true
end
-- OS-window space, called after GameViewport.finish so the overlay rides on
-- top of the game, companion composition and post-processing without being
-- captured or scaled with any game viewport. Also used by the launcher layout
-- editor under preview mode.
function TouchControls:draw()
if not self:visible() then return end
if TouchSkin.active then
local mul = (self.preview and self.enabled == false) and 0.45 or 1
if self:drawSkin(mul) then return end
end
local L = self:layout()
-- when the player disabled the overlay but the editor is previewing,
-- draw dimmed so the layout is still editable
+842
View File
@@ -0,0 +1,842 @@
local TouchSkin = {}
TouchSkin.BUNDLED_ROOT = "assets/skins"
TouchSkin.USER_ROOT = "skins"
TouchSkin.GB_BUTTONS = {
a = "a", b = "b", start = "start", select = "select",
up = "up", down = "down", left = "left", right = "right",
}
TouchSkin.HOTKEYS = {
overlay_next = "overlay_next",
overlay_previous = "overlay_prev",
menu_toggle = "menu",
reset = "soft_reset",
hold_fast_forward = "fast_forward_hold",
fast_forward = "fast_forward_hold",
toggle_fast_forward = "fast_forward_toggle",
screenshot = "screenshot",
pause_toggle = "pause",
exit_emulator = "quit",
}
local DEFAULT_ASPECT = 16 / 9
local PORTRAIT_ASPECT = 9 / 16
local function trim(s)
return (tostring(s or ""):match("^%s*(.-)%s*$"))
end
local function unquote(s)
s = trim(s)
local inner = s:match('^"(.*)"$')
return inner or s
end
local function toBool(v)
v = trim(v):lower()
return v == "true" or v == "1"
end
local function tokens(s)
local out = {}
for tok in tostring(s or ""):gmatch("[^,%s]+") do out[#out + 1] = tok end
return out
end
local function num(v, fallback)
local n = tonumber(trim(v))
if not n or n ~= n then return fallback end
return n
end
function TouchSkin.parseConfig(text)
local kv = {}
for line in tostring(text or ""):gmatch("[^\r\n]+") do
if not line:match("^%s*#") then
local key, value = line:match("^%s*([%w_]+)%s*=%s*(.-)%s*$")
if key then kv[key] = unquote(value) end
end
end
return kv
end
local function parseBinds(spec)
local buttons, hotkeys, keys, decorative = {}, {}, {}, true
for raw in tostring(spec or ""):gmatch("[^|]+") do
local name = trim(raw):lower()
local key = name:match("^key:(.+)$") or name:match("^retrok_(.+)$")
if name == "nul" or name == "" then
-- decoration
elseif key then
keys[#keys + 1] = key
decorative = false
elseif TouchSkin.GB_BUTTONS[name] then
buttons[#buttons + 1] = TouchSkin.GB_BUTTONS[name]
decorative = false
elseif TouchSkin.HOTKEYS[name] then
hotkeys[#hotkeys + 1] = TouchSkin.HOTKEYS[name]
decorative = false
end
end
return buttons, hotkeys, keys, decorative
end
local function parseDesc(kv, prefix, page)
local spec = kv[prefix]
if not spec then return nil end
local t = tokens(spec)
if #t < 6 then return nil end
local shape = trim(t[4]):lower()
if shape ~= "radial" and shape ~= "rect" then shape = "rect" end
local buttons, hotkeys, keys, decorative = parseBinds(t[1])
local reachX = num(kv[prefix .. "_reach_x"], 1)
local reachY = num(kv[prefix .. "_reach_y"], 1)
local ctl = {
spec = t[1],
buttons = buttons,
hotkeys = hotkeys,
keys = keys,
decorative = decorative,
x = num(t[2], 0.5),
y = num(t[3], 0.5),
shape = shape,
rangeX = math.abs(num(t[5], 0.05)),
rangeY = math.abs(num(t[6], 0.05)),
rangeMod = num(kv[prefix .. "_range_mod"], page.rangeMod),
alphaMod = num(kv[prefix .. "_alpha_mod"], page.alphaMod),
reachUp = num(kv[prefix .. "_reach_up"], reachY),
reachDown = num(kv[prefix .. "_reach_down"], reachY),
reachLeft = num(kv[prefix .. "_reach_left"], reachX),
reachRight = num(kv[prefix .. "_reach_right"], reachX),
imagePath = kv[prefix .. "_overlay"],
pressedImagePath = kv[prefix .. "_overlay_pressed"],
nextTarget = kv[prefix .. "_next_target"],
}
if ctl.imagePath == "" then ctl.imagePath = nil end
if ctl.pressedImagePath == "" then ctl.pressedImagePath = nil end
return ctl
end
function TouchSkin.parse(text)
local kv = TouchSkin.parseConfig(text)
local count = math.floor(num(kv.overlays, 0))
if count <= 0 then return nil, "no overlays" end
local pages = {}
for i = 0, count - 1 do
local p = "overlay" .. i
local page = {
index = i + 1,
name = kv[p .. "_name"] or ("overlay" .. i),
imagePath = kv[p .. "_overlay"],
fullScreen = toBool(kv[p .. "_full_screen"]),
normalized = toBool(kv[p .. "_normalized"]),
rangeMod = num(kv[p .. "_range_mod"], 1),
alphaMod = num(kv[p .. "_alpha_mod"], 1),
aspect = num(kv[p .. "_aspect_ratio"], nil),
controls = {},
}
if page.imagePath == "" then page.imagePath = nil end
if not page.aspect or page.aspect <= 0 then
page.aspect = page.name:lower():find("portrait", 1, true)
and PORTRAIT_ASPECT or DEFAULT_ASPECT
end
local rect = tokens(kv[p .. "_rect"])
page.rect = { x = 0, y = 0, w = 1, h = 1 }
if #rect >= 4 then
page.rect = { x = num(rect[1], 0), y = num(rect[2], 0),
w = num(rect[3], 1), h = num(rect[4], 1) }
end
local vp = tokens(kv[p .. "_viewport"])
if #vp >= 4 then
page.viewport = { x = num(vp[1], 0), y = num(vp[2], 0),
w = num(vp[3], 1), h = num(vp[4], 1) }
page.viewportFill = toBool(kv[p .. "_viewport_fill"])
page.viewportExpand = toBool(kv[p .. "_viewport_expand"])
end
local descs = math.floor(num(kv[p .. "_descs"], 0))
for d = 0, descs - 1 do
local ctl = parseDesc(kv, p .. "_desc" .. d, page)
if ctl then page.controls[#page.controls + 1] = ctl end
end
pages[#pages + 1] = page
end
return { pages = pages }
end
local function readFile(path)
if love and love.filesystem and love.filesystem.read then
local ok, data = pcall(love.filesystem.read, path)
if ok and data then return data end
end
local handle = io.open(path, "rb")
if not handle then return nil end
local data = handle:read("*a")
handle:close()
return data
end
local function listDir(path)
if love and love.filesystem and love.filesystem.getDirectoryItems then
local ok, items = pcall(love.filesystem.getDirectoryItems, path)
if ok and items then return items end
end
return {}
end
local function isDir(path)
if love and love.filesystem and love.filesystem.getInfo then
local ok, info = pcall(love.filesystem.getInfo, path)
if ok and info then return info.type == "directory" end
end
return false
end
local function joinPath(root, rel)
rel = tostring(rel or ""):gsub("^%./", ""):gsub("\\", "/")
if rel:sub(1, 1) == "/" then return rel:sub(2) end
return root .. "/" .. rel
end
TouchSkin.NATIVE_NAME = "skin.lua"
local function findConfig(root)
if readFile(root .. "/" .. TouchSkin.NATIVE_NAME) then
return root .. "/" .. TouchSkin.NATIVE_NAME, "native"
end
local named = { "overlay.cfg", "skin.cfg", "layout.cfg" }
for _, name in ipairs(named) do
if readFile(root .. "/" .. name) then return root .. "/" .. name, "retroarch" end
end
local items = listDir(root)
table.sort(items)
for _, name in ipairs(items) do
if name:match("%.cfg$") then return root .. "/" .. name, "retroarch" end
end
return nil
end
local function loadDataChunk(text, name)
if loadstring then
local chunk, err = loadstring(text, name)
if not chunk then return nil, err end
return setfenv(chunk, {})
end
return load(text, name, "t", {})
end
function TouchSkin.parseNative(text)
local chunk, err = loadDataChunk(tostring(text or ""), "skin")
if not chunk then return nil, "skin.lua does not parse: " .. tostring(err) end
local ok, data = pcall(chunk)
if not ok or type(data) ~= "table" then return nil, "skin.lua returned no table" end
if type(data.pages) ~= "table" or not data.pages[1] then
return nil, "skin.lua has no pages"
end
local pages = {}
for i, raw in ipairs(data.pages) do
if type(raw) ~= "table" then return nil, "page " .. i .. " is not a table" end
local page = {
index = i,
name = tostring(raw.name or ("page" .. i)),
imagePath = raw.image,
fullScreen = raw.fullScreen ~= false,
normalized = true,
rangeMod = num(raw.rangeMod, 1),
alphaMod = num(raw.alphaMod, 1),
aspect = num(raw.aspect, DEFAULT_ASPECT),
rect = { x = 0, y = 0, w = 1, h = 1 },
controls = {},
}
if type(raw.rect) == "table" then
page.rect = { x = num(raw.rect.x, 0), y = num(raw.rect.y, 0),
w = num(raw.rect.w, 1), h = num(raw.rect.h, 1) }
end
if type(raw.viewport) == "table" then
page.viewport = { x = num(raw.viewport.x, 0), y = num(raw.viewport.y, 0),
w = num(raw.viewport.w, 1), h = num(raw.viewport.h, 1) }
page.viewportFill = raw.viewport.fill == true
page.viewportExpand = raw.viewport.expand == true
end
for _, c in ipairs(raw.controls or {}) do
local buttons, hotkeys, keys, decorative = parseBinds(c.bind or "nul")
page.controls[#page.controls + 1] = {
spec = tostring(c.bind or "nul"),
buttons = buttons, hotkeys = hotkeys, keys = keys,
decorative = decorative,
x = num(c.x, 0.5), y = num(c.y, 0.5),
shape = c.shape == "radial" and "radial" or "rect",
rangeX = math.abs(num(c.w, 0.1)) * 0.5,
rangeY = math.abs(num(c.h, 0.1)) * 0.5,
rangeMod = num(c.rangeMod, page.rangeMod),
alphaMod = num(c.alphaMod, page.alphaMod),
reachUp = num(c.reachUp, 1), reachDown = num(c.reachDown, 1),
reachLeft = num(c.reachLeft, 1), reachRight = num(c.reachRight, 1),
imagePath = c.image,
pressedImagePath = c.imagePressed,
nextTarget = c.nextTarget,
}
end
pages[#pages + 1] = page
end
return { pages = pages, name = data.name, author = data.author,
notes = data.notes, format = "native" }
end
function TouchSkin.toNative(skin)
local out = {
name = skin.name or skin.id,
author = skin.author,
notes = skin.notes,
format = 1,
pages = {},
}
for _, page in ipairs(skin.pages or {}) do
local p = {
name = page.name,
image = page.imagePath,
fullScreen = page.fullScreen ~= false,
rangeMod = page.rangeMod,
alphaMod = page.alphaMod,
aspect = page.aspect,
controls = {},
}
if page.rect and (page.rect.x ~= 0 or page.rect.y ~= 0
or page.rect.w ~= 1 or page.rect.h ~= 1) then
p.rect = { x = page.rect.x, y = page.rect.y, w = page.rect.w, h = page.rect.h }
end
if page.viewport then
p.viewport = {
x = page.viewport.x, y = page.viewport.y,
w = page.viewport.w, h = page.viewport.h,
fill = page.viewportFill or nil,
expand = page.viewportExpand or nil,
}
end
for _, ctl in ipairs(page.controls or {}) do
p.controls[#p.controls + 1] = {
bind = ctl.spec,
x = ctl.x, y = ctl.y,
w = ctl.rangeX * 2, h = ctl.rangeY * 2,
shape = ctl.shape,
rangeMod = ctl.rangeMod ~= p.rangeMod and ctl.rangeMod or nil,
alphaMod = ctl.alphaMod ~= p.alphaMod and ctl.alphaMod or nil,
reachUp = ctl.reachUp ~= 1 and ctl.reachUp or nil,
reachDown = ctl.reachDown ~= 1 and ctl.reachDown or nil,
reachLeft = ctl.reachLeft ~= 1 and ctl.reachLeft or nil,
reachRight = ctl.reachRight ~= 1 and ctl.reachRight or nil,
image = ctl.imagePath,
imagePressed = ctl.pressedImagePath,
nextTarget = ctl.nextTarget,
}
end
out.pages[#out.pages + 1] = p
end
return out
end
function TouchSkin.serialize(skin)
return require("src.import.LuaWriter").encode(TouchSkin.toNative(skin))
end
local imageCache = setmetatable({}, { __mode = "v" })
local function loadImage(path)
if not (love and love.graphics and love.graphics.newImage) then return nil end
local cached = imageCache[path]
if cached then return cached end
local ok, img = pcall(love.graphics.newImage, path)
if not ok or not img then return nil end
if img.setFilter then img:setFilter("linear", "linear") end
imageCache[path] = img
return img
end
function TouchSkin.load(root, id)
local cfgPath, format = findConfig(root)
if not cfgPath then return nil, "no skin.lua or .cfg in " .. root end
local text = readFile(cfgPath)
if not text then return nil, "unreadable " .. cfgPath end
local skin, err
if format == "native" then
skin, err = TouchSkin.parseNative(text)
else
skin, err = TouchSkin.parse(text)
end
if not skin then return nil, err end
skin.id = id or root:match("([^/]+)$") or root
skin.root = root
skin.configPath = cfgPath
skin.format = format
skin.name = skin.name or skin.pages[1] and skin.pages[1].name or skin.id
for _, page in ipairs(skin.pages) do
if page.imagePath then
page.image = loadImage(joinPath(root, page.imagePath))
end
for _, ctl in ipairs(page.controls) do
if ctl.imagePath then ctl.image = loadImage(joinPath(root, ctl.imagePath)) end
if ctl.pressedImagePath then
ctl.pressedImage = loadImage(joinPath(root, ctl.pressedImagePath))
end
end
end
return skin
end
local function mountZip(archive, point)
if not (love and love.filesystem and love.filesystem.mount) then return false end
local ok, mounted = pcall(love.filesystem.mount, archive, point)
return ok and mounted == true
end
function TouchSkin.list()
local out, seen = {}, {}
local function scan(root, source)
for _, name in ipairs(listDir(root)) do
local id = name:gsub("%.zip$", "")
if not seen[id] then
local path = root .. "/" .. name
if name:match("%.zip$") then
local point = TouchSkin.USER_ROOT .. "/_mounted/" .. id
if mountZip(path, point) and findConfig(point) then
seen[id] = true
out[#out + 1] = { id = id, root = point, source = source, archive = path }
end
elseif isDir(path) and findConfig(path) then
seen[id] = true
out[#out + 1] = { id = id, root = path, source = source }
end
end
end
end
if love and love.filesystem and love.filesystem.createDirectory then
pcall(love.filesystem.createDirectory, TouchSkin.USER_ROOT)
end
scan(TouchSkin.USER_ROOT, "user")
scan(TouchSkin.BUNDLED_ROOT, "bundled")
table.sort(out, function(a, b) return a.id < b.id end)
return out
end
-- Drop a .zip into <save>/skins and report the id it will list under.
function TouchSkin.installArchive(name, data)
if not data or data == "" then return nil, "empty archive" end
if not (love and love.filesystem and love.filesystem.write) then
return nil, "no writable filesystem"
end
name = tostring(name or ""):match("([^/\\]+)$") or ""
name = name:gsub("[^%w%._%-]", "_")
if not name:lower():match("%.zip$") then return nil, "not a .zip" end
local id = name:gsub("%.[Zz][Ii][Pp]$", "")
if id == "" then return nil, "bad archive name" end
pcall(love.filesystem.createDirectory, TouchSkin.USER_ROOT)
local dest = TouchSkin.USER_ROOT .. "/" .. name
local ok, err = love.filesystem.write(dest, data)
if not ok then return nil, tostring(err) end
local entry = TouchSkin.find(id)
if not entry then
love.filesystem.remove(dest)
return nil, "no skin.lua or .cfg inside " .. name
end
return id
end
function TouchSkin.find(id)
if not id or id == "" then return nil end
for _, entry in ipairs(TouchSkin.list()) do
if entry.id == id then return entry end
end
return nil
end
function TouchSkin.assetPaths(skin)
local out, seen = {}, {}
local function add(rel)
if rel and rel ~= "" and not seen[rel] then
seen[rel] = true
out[#out + 1] = rel
end
end
for _, page in ipairs(skin.pages or {}) do
add(page.imagePath)
for _, ctl in ipairs(page.controls or {}) do
add(ctl.imagePath)
add(ctl.pressedImagePath)
end
end
return out
end
function TouchSkin.export(skin, destPath)
if not skin then return nil, "no skin" end
local SkinZip = require("src.core.SkinZip")
local entries = { { name = TouchSkin.NATIVE_NAME, data = TouchSkin.serialize(skin) } }
local missing = {}
for _, rel in ipairs(TouchSkin.assetPaths(skin)) do
local data = readFile(joinPath(skin.root, rel))
if data then
entries[#entries + 1] = { name = rel, data = data }
else
missing[#missing + 1] = rel
end
end
if skin.configPath and skin.format == "retroarch" then
local cfg = readFile(skin.configPath)
if cfg then
entries[#entries + 1] =
{ name = skin.configPath:match("([^/]+)$") or "overlay.cfg", data = cfg }
end
end
local blob = SkinZip.encode(entries)
destPath = destPath or (TouchSkin.USER_ROOT .. "/" .. skin.id .. "-export.zip")
local absolute = destPath:sub(1, 1) == "/" or destPath:match("^%a:[/\\]") ~= nil
if not absolute and love and love.filesystem and love.filesystem.write then
local ok, err = love.filesystem.write(destPath, blob)
if not ok then return nil, tostring(err) end
else
local handle = io.open(destPath, "wb")
if not handle then return nil, "cannot write " .. destPath end
handle:write(blob)
handle:close()
end
return destPath, missing
end
TouchSkin.BINDS = {
"nul",
"up", "down", "left", "right",
"a", "b", "start", "select",
"left|up", "right|up", "left|down", "right|down",
"hold_fast_forward", "toggle_fast_forward", "reset", "menu_toggle",
"overlay_next",
}
function TouchSkin.describeBind(spec)
local buttons, hotkeys, keys, decorative = parseBinds(spec)
if decorative then return "decoration" end
local parts = {}
for _, b in ipairs(buttons) do parts[#parts + 1] = "GB " .. b:upper() end
for _, h in ipairs(hotkeys) do parts[#parts + 1] = h end
for _, k in ipairs(keys) do parts[#parts + 1] = "key " .. k end
return table.concat(parts, " + ")
end
function TouchSkin.newControl(spec, x, y, w, h, shape)
local buttons, hotkeys, keys, decorative = parseBinds(spec)
return {
spec = spec, buttons = buttons, hotkeys = hotkeys, keys = keys,
decorative = decorative,
x = x, y = y, rangeX = w * 0.5, rangeY = h * 0.5,
shape = shape == "radial" and "radial" or "rect",
rangeMod = 1, alphaMod = 1,
reachUp = 1, reachDown = 1, reachLeft = 1, reachRight = 1,
}
end
function TouchSkin.setBind(ctl, spec)
ctl.spec = spec
ctl.buttons, ctl.hotkeys, ctl.keys, ctl.decorative = parseBinds(spec)
return ctl
end
function TouchSkin.newSkin(id)
local page = {
index = 1, name = "main", fullScreen = true, normalized = true,
rangeMod = 1, alphaMod = 1, aspect = PORTRAIT_ASPECT,
rect = { x = 0, y = 0, w = 1, h = 1 },
viewport = { x = 0, y = 0, w = 1, h = 0.5 },
viewportFill = false,
controls = {},
}
return { id = id or "new_skin", name = id or "new_skin",
root = TouchSkin.USER_ROOT .. "/" .. (id or "new_skin"),
format = "native", pages = { page } }
end
local function copyTable(src)
if type(src) ~= "table" then return src end
local out = {}
for k, v in pairs(src) do out[k] = copyTable(v) end
return out
end
function TouchSkin.clone(skin)
if not skin then return nil end
local out = {
id = skin.id, name = skin.name, root = skin.root, format = skin.format,
author = skin.author, notes = skin.notes, configPath = skin.configPath,
source = skin.source, pages = {},
}
for i, page in ipairs(skin.pages or {}) do
local p = copyTable(page)
p.image = page.image
p.controls = {}
for j, ctl in ipairs(page.controls or {}) do
local c = copyTable(ctl)
c.image = ctl.image
c.pressedImage = ctl.pressedImage
p.controls[j] = c
end
out.pages[i] = p
end
return out
end
function TouchSkin.resolveImage(root, rel)
if not rel or rel == "" then return nil end
return loadImage(joinPath(root, rel))
end
function TouchSkin.detectViewport(root, imagePath)
if not (love and love.image and love.image.newImageData) then return nil end
if not imagePath or imagePath == "" then return nil end
local ok, data = pcall(love.image.newImageData, joinPath(root, imagePath))
if not ok or not data then return nil end
local w, h = data:getWidth(), data:getHeight()
if w < 2 or h < 2 then return nil end
local function clear(x, y)
if x < 0 or y < 0 or x >= w or y >= h then return false end
local okp, _, _, _, a = pcall(data.getPixel, data, x, y)
return okp and a ~= nil and a < 0.05
end
local cx, cy = math.floor(w / 2), math.floor(h / 2)
if not clear(cx, cy) then return nil end
local left, right = cx, cx
while left > 0 and clear(left - 1, cy) do left = left - 1 end
while right < w - 1 and clear(right + 1, cy) do right = right + 1 end
local top, bottom = cy, cy
while top > 0 and clear(cx, top - 1) do top = top - 1 end
while bottom < h - 1 and clear(cx, bottom + 1) do bottom = bottom + 1 end
local rw, rh = right - left + 1, bottom - top + 1
if rw < 8 or rh < 8 then return nil end
return { x = left / w, y = top / h, w = rw / w, h = rh / h }, rw, rh
end
-- Bring outside art into the skin's own folder. A skin still living in
-- assets/ or a mounted zip is saved out to <save>/skins/<id> first, because
-- that is the only root the engine can write to.
function TouchSkin.importImage(skin, name, data)
if not skin then return nil, "no skin" end
if not data or data == "" then return nil, "no image data" end
if not (love and love.filesystem and love.filesystem.write) then
return nil, "no writable filesystem"
end
name = tostring(name):gsub("[^%w%._%-]", "_")
if name == "" then return nil, "bad file name" end
local dest = TouchSkin.USER_ROOT .. "/" .. skin.id
if skin.root ~= dest then
local saved, err = TouchSkin.saveTo(skin, skin.id)
if not saved then return nil, tostring(err) end
end
pcall(love.filesystem.createDirectory, dest .. "/img")
local rel = "img/" .. name
local ok, err = love.filesystem.write(dest .. "/" .. rel, data)
if not ok then return nil, tostring(err) end
return rel
end
function TouchSkin.listImages(root)
local out = {}
local function scan(dir, prefix)
for _, name in ipairs(listDir(dir)) do
local path = dir .. "/" .. name
if name:lower():match("%.png$") or name:lower():match("%.jpg$") then
out[#out + 1] = prefix .. name
elseif isDir(path) and prefix == "" then
scan(path, name .. "/")
end
end
end
scan(root, "")
table.sort(out)
return out
end
function TouchSkin.saveTo(skin, id)
id = id or skin.id
if not id or id == "" then return nil, "no skin id" end
if not (love and love.filesystem and love.filesystem.write) then
return nil, "no writable filesystem"
end
local dest = TouchSkin.USER_ROOT .. "/" .. id
pcall(love.filesystem.createDirectory, dest)
local copied, failed = 0, {}
for _, rel in ipairs(TouchSkin.assetPaths(skin)) do
local target = dest .. "/" .. rel
local dir = target:match("^(.*)/[^/]+$")
if dir then pcall(love.filesystem.createDirectory, dir) end
if skin.root ~= dest then
local data = readFile(joinPath(skin.root, rel))
if data then
if love.filesystem.write(target, data) then copied = copied + 1 end
else
failed[#failed + 1] = rel
end
end
end
local ok, err = love.filesystem.write(dest .. "/" .. TouchSkin.NATIVE_NAME,
TouchSkin.serialize(skin))
if not ok then return nil, tostring(err) end
skin.id, skin.root, skin.format = id, dest, "native"
return dest, failed, copied
end
TouchSkin.active = nil
TouchSkin.pageIndex = 1
TouchSkin.surfaceRect = nil
function TouchSkin.setSurface(x, y, w, h)
if not w or w <= 0 or not h or h <= 0 then
TouchSkin.surfaceRect = nil
else
TouchSkin.surfaceRect = { x = x, y = y, w = w, h = h }
end
return TouchSkin.surfaceRect
end
function TouchSkin.setActive(skin)
TouchSkin.active = skin or nil
TouchSkin.pageIndex = 1
return TouchSkin.active
end
function TouchSkin.select(id)
if not id or id == "" then return TouchSkin.setActive(nil) end
local entry = TouchSkin.find(id)
if not entry then return nil, "no skin " .. tostring(id) end
local skin, err = TouchSkin.load(entry.root, entry.id)
if not skin then return nil, err end
skin.source = entry.source
return TouchSkin.setActive(skin)
end
function TouchSkin.page()
local skin = TouchSkin.active
if not skin then return nil end
return skin.pages[TouchSkin.pageIndex] or skin.pages[1]
end
function TouchSkin.setPage(target)
local skin = TouchSkin.active
if not skin then return nil end
if type(target) == "number" then
local n = #skin.pages
TouchSkin.pageIndex = ((math.floor(target) - 1) % n) + 1
return TouchSkin.page()
end
for i, page in ipairs(skin.pages) do
if page.name == target then
TouchSkin.pageIndex = i
return page
end
end
return TouchSkin.page()
end
function TouchSkin.nextPage(target)
local skin = TouchSkin.active
if not skin then return nil end
if target and target ~= "" then return TouchSkin.setPage(target) end
return TouchSkin.setPage(TouchSkin.pageIndex + 1)
end
function TouchSkin.pageBox(page, w, h, ox, oy)
ox, oy = ox or 0, oy or 0
if not page then return ox, oy, w, h end
local bx, by, bw, bh = ox, oy, w, h
if not page.fullScreen and h > 0 then
local displayAspect = w / h
if displayAspect > page.aspect then
bw = h * page.aspect
bx = ox + (w - bw) * 0.5
else
bh = w / page.aspect
by = oy + (h - bh) * 0.5
end
end
local r = page.rect
return bx + r.x * bw, by + r.y * bh, r.w * bw, r.h * bh
end
function TouchSkin.controlGeometry(page, ctl, w, h, ox, oy)
local bx, by, bw, bh = TouchSkin.pageBox(page, w, h, ox, oy)
local cx, cy = bx + ctl.x * bw, by + ctl.y * bh
local halfW, halfH = ctl.rangeX * bw, ctl.rangeY * bh
return cx, cy, halfW, halfH
end
function TouchSkin.hits(page, ctl, w, h, px, py, ox, oy)
local cx, cy, halfW, halfH = TouchSkin.controlGeometry(page, ctl, w, h, ox, oy)
local left = halfW * ctl.reachLeft * ctl.rangeMod
local right = halfW * ctl.reachRight * ctl.rangeMod
local up = halfH * ctl.reachUp * ctl.rangeMod
local down = halfH * ctl.reachDown * ctl.rangeMod
local dx = px - cx
local dy = py - cy
local rx = dx < 0 and left or right
local ry = dy < 0 and up or down
if rx <= 0 or ry <= 0 then return false end
if ctl.shape == "radial" then
return (dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1
end
return math.abs(dx) <= rx and math.abs(dy) <= ry
end
TouchSkin.overlayLive = false
function TouchSkin.setOverlayLive(on)
TouchSkin.overlayLive = on and true or false
end
function TouchSkin.decorativeOnly()
local page = TouchSkin.page()
if not page then return false end
for _, ctl in ipairs(page.controls) do
if not ctl.decorative then return false end
end
return true
end
function TouchSkin.drawable()
if not TouchSkin.active then return false end
return TouchSkin.overlayLive or TouchSkin.decorativeOnly()
end
function TouchSkin.hasViewport()
local page = TouchSkin.page()
return page ~= nil and page.viewport ~= nil and TouchSkin.drawable()
end
function TouchSkin.viewport(w, h, ox, oy)
local page = TouchSkin.page()
if not page or not page.viewport or not TouchSkin.drawable() then return nil end
local v = page.viewport
local x, y = (ox or 0) + v.x * w, (oy or 0) + v.y * h
local vw, vh = v.w * w, v.h * h
if vw <= 0 or vh <= 0 then return nil end
return x, y, vw, vh, page.viewportFill == true, page.viewportExpand == true
end
return TouchSkin
+30
View File
@@ -101,6 +101,14 @@ function Boxes.canDeposit(save, partyIndex, boxIndex)
return true
end
-- RestorePPOfDepositedPokemon (engine/pokemon/move_mon.asm:711-773): every
-- slot back to GetMaxPPOfMove, which already carries its own PP Up count.
function Boxes.restorePP(mon)
for _, move in ipairs((mon and mon.moves) or {}) do
if type(move) == "table" then move.pp = move.maxPp or move.pp end
end
end
function Boxes.deposit(save, partyIndex, boxIndex)
local ok, reason = Boxes.canDeposit(save, partyIndex, boxIndex)
if not ok then return false, reason end
@@ -112,6 +120,9 @@ function Boxes.deposit(save, partyIndex, boxIndex)
Mail.removeSlot(save, partyIndex)
local box = Boxes.box(save, boxIndex)
box[#box + 1] = mon
-- SendGetMonIntoFromBox's PC_DEPOSIT arm ends in RestorePPOfDepositedPokemon
-- (engine/pokemon/move_mon.asm:633-635, :696-700).
Boxes.restorePP(mon)
return true, mon
end
@@ -129,6 +140,15 @@ function Boxes.withdraw(save, boxIndex, slot)
local ok, reason = Boxes.canWithdraw(save, boxIndex, slot)
if not ok then return false, reason end
local mon = table.remove(Boxes.box(save, boxIndex), slot)
-- The `get mon into Party` arm alone heals: status cleared and MON_MAXHP
-- copied over MON_HP, an egg's staying 0 (move_mon.asm:666-693).
mon.status = nil
mon.statusTurns = nil
if mon.isEgg then
mon.hp = 0
else
mon.hp = mon.maxHp or mon.hp
end
save.party = save.party or {}
save.party[#save.party + 1] = mon
return true, mon
@@ -142,6 +162,16 @@ function Boxes.release(save, boxIndex, slot)
return true, table.remove(box, slot)
end
-- RELEASE off the DEPOSIT screen, whose list is the party: it is
-- RemoveMonFromPartyOrBox's REMOVE_PARTY arm (bills_pc.asm:204-207).
function Boxes.releaseFromParty(save, slot)
local party = (save and save.party) or {}
if not party[slot] then return false, "There is no POKéMON there." end
local mon = table.remove(party, slot)
Mail.removeSlot(save, slot)
return true, mon
end
-- Move a boxed mon to another box (MOVE PKMN W/O MAIL's box-to-box case).
function Boxes.move(save, fromBox, slot, toBox)
if fromBox == toBox then return false, "It's already there." end
+7
View File
@@ -159,6 +159,13 @@ function NpcTrade.perform(data, save, row, index)
-- that slot inheriting it (src/core/gen2/Mail.lua).
Mail.removeSlot(save, index)
party[#party + 1] = received
-- TryAddMonToParty's SetSeenAndCaughtMon (engine/pokemon/move_mon.asm:196),
-- which the `predef` at engine/events/npc_trade.asm:168 runs like any other.
save.pokedex = save.pokedex or {}
save.pokedex.seen = save.pokedex.seen or {}
save.pokedex.caught = save.pokedex.caught or {}
save.pokedex.seen[received.species] = true
save.pokedex.caught[received.species] = true
return given, received
end
+1
View File
@@ -273,6 +273,7 @@ Save.DEFAULT_OPTIONS = {
-- means something different, hence the different name.
color = "gbc",
videoMode = "windowed",
fpsCap = 60,
musicVol = 7, -- 0-7, like the GB's NR50 master volume
sfxVol = 7, -- 0-7
musicFilter = 0, -- low-pass steps, 0 = off
+22
View File
@@ -122,6 +122,17 @@ local function addTouchRows(rows, add, opts, hooks)
end,
}
end
if hooks and hooks.openSkinStudio then
rows[#rows + 1] = {
label = Strings("SKIN STUDIO"),
actionLabel = Strings("Open"),
action = function()
hooks.openSkinStudio()
return false
end,
}
end
end
local function coreRows(opts, hooks)
@@ -627,6 +638,16 @@ local function gen2Rows(opts, hooks)
end)
end
local okCap, FrameCap = pcall(require, "src.core.FrameCap")
if okCap then
add(Strings("MAX FPS"),
function() return FrameCap.label(opts.fpsCap) end,
function(dir)
opts.fpsCap = FrameCap.cycle(opts.fpsCap, dir)
return true
end)
end
addTouchRows(rows, add, opts, hooks)
return rows
@@ -638,6 +659,7 @@ end
-- writes options while a modal covers it, so the cached table stays true.
-- `hooks` carries the host actions a row cannot perform itself:
-- editTouchControls() -- hand the screen to the touch-overlay editor
-- openSkinStudio() -- hand the screen to the desktop skin studio
--
-- `version` is the game the gear was opened on. It picks the row set, and
-- for Gold it also picks WHICH table the rows edit: the `gold` block inside
+246 -5
View File
@@ -776,6 +776,26 @@ end
-- A hand-drawn X / check: the UI font has no guaranteed glyph for either,
-- and the launcher ships no icon asset for them.
-- Skins tab glyph: a bezel with a screen cutout and two face buttons, drawn
-- rather than shipped as art so the tab needs no new asset.
local function drawSkinGlyph(x, y, w, h, hot)
local box = math.min(w, h)
local bx = x + (w - box) / 2
local by = y + (h - box) / 2
local pad = math.floor(box * 0.22)
local ow, oh = box - 2 * pad, box - 2 * pad
local ink = hot and PAL.inverse or PAL.ink
local a = 1
Theme.strokeRounded(bx + pad, by + pad, ow, oh, ink, a,
math.max(1, math.floor(Kit.scale)), math.floor(oh * 0.22))
local sw, sh = ow * 0.58, oh * 0.40
Theme.fillRounded(bx + pad + ow * 0.10, by + pad + oh * 0.14, sw, sh, ink, a,
math.max(1, math.floor(sh * 0.2)))
local r = math.max(1, oh * 0.09)
Theme.fillRounded(bx + pad + ow * 0.60, by + pad + oh * 0.64, r * 2, r * 2, ink, a, r)
Theme.fillRounded(bx + pad + ow * 0.80, by + pad + oh * 0.50, r * 2, r * 2, ink, a, r)
end
local function drawCross(x, y, size, color)
love.graphics.push("all")
love.graphics.setColor(color)
@@ -807,18 +827,46 @@ end
-- frame. Their tab rows, opts tables and action closures are built once
-- instead of 60 times a second -- only `active`, `image` and the queued
-- action are written per frame.
-- The four cartridges used to be four tabs of their own. They are one
-- dropdown now: the tab row was seven controls wide and wrapped to two rows on
-- anything narrow, and only ever one game is being looked at.
local GAME_TABS = {
{ id = "red", key = "tab-red", letter = "R", color = PAL.railRed,
label = "Red" },
{ id = "blue", key = "tab-blue", letter = "B", color = PAL.railBlue,
label = "Blue" },
{ id = "yellow", key = "tab-yellow", letter = "Y", color = PAL.railGold,
label = "Yellow" },
{ id = "gold", key = "tab-gold", letter = "G", color = PAL.railAmber,
label = "Gold" },
}
local HEADER_TABS = {
{ id = "red", key = "tab-red", letter = "R", color = PAL.railRed },
{ id = "blue", key = "tab-blue", letter = "B", color = PAL.railBlue },
{ id = "yellow", key = "tab-yellow", letter = "Y", color = PAL.railGold },
{ id = "gold", key = "tab-gold", letter = "G", color = PAL.railAmber },
{ id = "mods", key = "tab-mods" },
{ id = "find", key = "tab-find" },
{ id = "skins", key = "tab-skins", glyph = true },
}
for _, t in ipairs(HEADER_TABS) do
t.opts = { face = "tab", font = "tab", color = t.color, letter = t.letter }
if t.glyph then t.opts.drawFn = drawSkinGlyph end
end
-- Which cartridge the dropdown is showing: the open game tab, else the last
-- one visited, else Red. Kept as a function so the mods/find/skins panels
-- still answer "for which game" without a game tab being open.
local function currentGame(imp)
for _, g in ipairs(GAME_TABS) do
if imp.tab == g.id then return g end
end
for _, g in ipairs(GAME_TABS) do
if imp.modScope == g.id then return g end
end
return GAME_TABS[1]
end
LauncherView.GAME_TABS = GAME_TABS
LauncherView.currentGame = currentGame
local QUIT_INK_HOT = { 0, 0, 0, 1 }
local QUIT_INK_REST = { 1, 1, 1, 0.85 }
@@ -837,11 +885,27 @@ local function headerChrome(imp)
hot and QUIT_INK_HOT or QUIT_INK_REST)
end },
tab = {},
game = { face = "tab", font = "tab",
action = function()
local g = currentGame(imp)
if imp.tab == g.id then
imp._gamePopup = true
else
imp:_switchTab(g.id)
end
end },
}
for _, t in ipairs(HEADER_TABS) do
local id = t.id
c.tab[id] = function() imp:_switchTab(id) end
end
for _, g in ipairs(GAME_TABS) do
local id = g.id
c.tab[id] = function()
imp._gamePopup = nil
imp:_switchTab(id)
end
end
imp._headerChrome = c
return c
end
@@ -927,7 +991,10 @@ local function buildHeader(imp, m)
-- bright cart gold; Gold (Gen 2) uses the deeper amber so the two do not
-- collide.
local tabs = HEADER_TABS
tabs[5].icon, tabs[6].icon = imp._modsIcon, imp._findIcon
for _, t in ipairs(tabs) do
if t.id == "mods" then t.icon = imp._modsIcon end
if t.id == "find" then t.icon = imp._findIcon end
end
local tabH = m.chip
local tx = m.x + m.pad
local ty = y + math.floor(6 * m.s)
@@ -935,6 +1002,40 @@ local function buildHeader(imp, m)
local tabRight = m.x + m.w - m.pad
local tabGap = math.floor(6 * m.s)
local tabRowGap = math.floor(4 * m.s)
-- the cartridge dropdown, sized to its longest label so switching games
-- never reflows the row
local chrome0 = headerChrome(imp)
local game = currentGame(imp)
local labelW = 0
for _, g in ipairs(GAME_TABS) do
labelW = math.max(labelW, Kit.textWidth("tab", Strings(g.label)))
end
local dropW = math.min(tabRight - tabLeft,
tabH + labelW + math.floor(34 * m.s))
chrome0.game.color = game.color
chrome0.game.letter = game.letter
chrome0.game.active = imp.tab == game.id
local gameHot = Kit.hover(tx, ty, dropW, tabH)
local gameDown = gameHot and Kit.mouseDown
-- face "tab" inverts on hover as well as when active, so the caret has to
-- flip with it or it vanishes into the cartridge colour
local gameInvert = chrome0.game.active or gameHot
chrome0.game.ring = gameHot and not chrome0.game.active or nil
btn(imp, tx, ty, dropW, tabH, "tab-game", Strings(game.label), chrome0.game)
do
local cw = math.floor(7 * m.s)
local ccx = tx + dropW - math.floor(14 * m.s)
local ccy = ty + tabH / 2 + (gameDown and math.floor(1 * m.s) or 0)
if love.graphics.polygon then
Theme.col(gameInvert and PAL.inverse or PAL.ink, gameDown and 1 or 0.9)
love.graphics.polygon("fill",
ccx - cw, ccy - cw * 0.5, ccx + cw, ccy - cw * 0.5, ccx, ccy + cw * 0.8)
love.graphics.setColor(1, 1, 1, 1)
end
end
tx = tx + dropW + tabGap
for _, t in ipairs(tabs) do
local w = tabH
if tx > tabLeft and tx + w > tabRight then
@@ -1909,6 +2010,117 @@ end
-- ---------------------------------------------------------- find mods panel
-- SKINS tab: pick the on-screen skin, import one, or open the desktop studio.
local function buildSkinsPanel(imp, x, y, w, availH, m)
local skins = imp:_ensureSkins()
local active = imp:_activeSkin()
local gap = m.gap
local cy = y
if imp._skinNotice then
cy = cy + Kit.textWrapped("small", imp._skinNotice.text, x, cy, w,
imp._skinNotice.ok and PAL.green or PAL.red, 2) + math.floor(8 * m.s)
end
-- Studio button. Desktop only: the host supplies the hook nowhere else.
if imp.onOpenSkinStudio then
local label = Strings("Open Skin Studio")
local bw = math.min(w, Kit.textWidth("small", label) + math.floor(40 * m.s))
btn(imp, x, cy, bw, m.btnH, "skins-studio", label, {
kind = "accent", font = "small",
action = function()
-- the studio boots the game on Play, so hand it a real cartridge
imp.onOpenSkinStudio(imp.modScope or "red")
end })
local hint = Strings("Design bezels and button layouts, then test them.")
Kit.text("small", Kit.ellipsize("small", hint,
w - bw - math.floor(12 * m.s)), x + bw + math.floor(12 * m.s),
cy + math.floor((m.btnH - Kit.textHeight("small")) / 2), PAL.muted)
cy = cy + m.btnH + gap
end
Kit.caption(x, cy, Strings("INSTALLED"))
cy = cy + Kit.textHeight("small") + math.floor(6 * m.s)
local rowH = math.max(Kit.tapMin(), math.floor(44 * m.s))
imp._skinGear = imp._skinGear
or love.graphics.newImage("assets/launcher/gear.png")
-- The row itself is "use this skin"; the gear beside it configures that
-- entry -- the built-in pad opens the drag-a-button layout editor, a skin
-- opens the studio, so neither lands on a screen that cannot edit it.
local function skinRow(key, id, title, detail, selected, configure)
local gearW = configure and rowH or 0
local rowW = w - (gearW > 0 and (gearW + math.floor(6 * m.s)) or 0)
local ink = rowHit(imp, x, cy, rowW, rowH, selected, key,
function() imp:_useSkin(id) end)
local tagW = selected
and (Kit.textWidth("small", Strings("IN USE")) + math.floor(20 * m.s))
or math.floor(12 * m.s)
local textW = rowW - math.floor(24 * m.s) - tagW
local tx = x + math.floor(12 * m.s)
local ty = cy + math.floor(7 * m.s)
Kit.text("mono", Kit.ellipsize("mono", title, textW), tx, ty,
ink or PAL.heading)
Kit.text("small", Kit.ellipsize("small", detail, textW),
tx, ty + Kit.textHeight("mono"), ink or PAL.muted)
if selected then
Kit.textRight("small", Strings("IN USE"), x + rowW - math.floor(12 * m.s),
cy + math.floor((rowH - Kit.textHeight("small")) / 2), ink or PAL.green)
end
if configure then
btn(imp, x + w - gearW, cy, gearW, gearW, key .. "-cfg", "", {
face = "invert", image = imp._skinGear, action = configure })
end
cy = cy + rowH + math.floor(4 * m.s)
end
skinRow("skin-none", nil, Strings("Built-in pad"),
Strings("The default on-screen buttons."), active == nil,
imp.onEditTouchControls and function()
imp.onEditTouchControls(imp.modScope or "red")
end or nil)
for _, entry in ipairs(skins) do
local bits = {}
bits[#bits + 1] = entry.source == "user" and Strings("installed")
or Strings("bundled")
if entry.controls > 0 then
bits[#bits + 1] = entry.controls .. " " .. Strings("buttons")
else
bits[#bits + 1] = Strings("bezel only")
end
if entry.pages > 1 then
bits[#bits + 1] = entry.pages .. " " .. Strings("pages")
end
if entry.screen then bits[#bits + 1] = Strings("screen cutout") end
local configure = imp.onOpenSkinStudio and function()
imp.onOpenSkinStudio(imp.modScope or "red", entry.id)
end or nil
skinRow("skin-" .. entry.id, entry.id, entry.id,
table.concat(bits, " \194\183 "), active == entry.id, configure)
end
if #skins == 0 then
Kit.emptyBox(x, cy, w, math.floor(72 * m.s),
Strings("No skins installed yet."))
cy = cy + math.floor(72 * m.s) + gap
end
cy = cy + math.floor(6 * m.s)
local TouchSkin = require("src.core.TouchSkin")
Kit.caption(x, cy, Strings("IMPORT"))
cy = cy + Kit.textHeight("small") + math.floor(6 * m.s)
local boxH = math.floor(76 * m.s)
Kit.card(x, cy, w, boxH, "muted")
Kit.textWrapped("small", Strings(
"Drop a skin .zip on this window to install it, or put a folder in the skins folder of your save directory. RetroArch overlay .cfg files work as-is."),
x + math.floor(14 * m.s), cy + math.floor(12 * m.s),
w - math.floor(28 * m.s), PAL.muted, 3)
Kit.text("small", TouchSkin.USER_ROOT .. "/", x + math.floor(14 * m.s),
cy + boxH - Kit.textHeight("small") - math.floor(10 * m.s), PAL.faint)
end
local function buildFindPanel(imp, x, y, w, availH, m)
imp:_ensureFind()
imp:_ensureMods()
@@ -2780,6 +2992,32 @@ local function buildModScopeModal(imp, m)
action = function() imp._modScopePopup = nil end })
end
-- The cartridge dropdown's list. Replaces the four R/B/Y/G tabs, so it is
-- also what a controller reaches after the tab row.
local function buildGameModal(imp, m)
local pad = math.floor(18 * m.s)
local gap = math.floor(8 * m.s)
local w = math.floor(360 * m.s)
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s)
+ #GAME_TABS * (m.btnH + gap) + m.btnH + pad
local px, py, pw = modalPanel(m, w, h)
local cy = py + pad
Kit.text("button", Strings("Choose game"), px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("button") + math.floor(12 * m.s)
local chrome = headerChrome(imp)
for _, g in ipairs(GAME_TABS) do
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "gamepop-" .. g.id,
Strings(g.label), {
face = "tab", font = "small", letter = g.letter, color = g.color,
active = imp.tab == g.id,
action = chrome.tab[g.id] })
cy = cy + m.btnH + gap
end
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "gamepop-close",
Strings("Close"), { font = "small",
action = function() imp._gamePopup = nil end })
end
-- Category filter for FIND MODS. Two columns, because an index can list
-- enough categories to overflow a single stacked column on a short window.
local function buildFilterModal(imp, m)
@@ -3753,6 +3991,7 @@ local function buildModals(imp, m)
if imp._profilesPopup then buildProfilesModal(imp, m) return true end
if imp._modHeaderActionsPopup then buildModHeaderActionsModal(imp, m) return true end
if imp._sortPopup then buildSortModal(imp, m) return true end
if imp._gamePopup then buildGameModal(imp, m) return true end
if imp._modScopePopup then buildModScopeModal(imp, m) return true end
if imp._filterPopup then buildFilterModal(imp, m) return true end
if imp._indexManage then buildIndexesModal(imp, m) return true end
@@ -3906,6 +4145,8 @@ function LauncherView.draw(imp)
buildModsPanel(imp, x, contentY, w, availH, m)
elseif imp.tab == "find" then
buildFindPanel(imp, x, contentY, w, availH, m)
elseif imp.tab == "skins" then
buildSkinsPanel(imp, x, contentY, w, availH, m)
else
buildGamePanel(imp, x, contentY, w, availH, m, imp.tab)
end
+11
View File
@@ -49,6 +49,9 @@ local MAP_GROUP_COUNT = 26 -- constants/map_constants.asm NUM_MAP_GROUPS
-- EnvironmentColorsPointers, and the Tilesets row only stores a 16-bit
-- pointer, so the bank has to come from here.
local PAL_MAP_BANK = 0x02
-- LoadBallIconGFX.gfx (engine/battle/trainer_huds.asm:225-232); bank $0b
-- carries no manifest symbol to resolve it through.
local BALL_ICON_GFX = { 0x0b, 0x41a4 }
-- A tileset sheet is 96 tiles (128x48 at 8x8), and its PalMap packs two
-- tiles per byte: low nibble first tile, high nibble second (`dn` in the
-- tilepal macro). The high bit of each nibble is the VRAM bank, not colour.
@@ -4912,6 +4915,14 @@ function RomExtractorGen2:extractMenuGfx()
hud.expBarFirstTile = 0x55
hud.expBarCells = 9
-- Four OAM tiles at $31 -- normal, statused, fainted, empty -- and OBJ
-- colour 0 is transparent (engine/battle/trainer_huds.asm:47-99, :225-232).
local balls = self.symbols["LoadBallIconGFX.gfx"] or BALL_ICON_GFX
self:write2bpp(self.rom:bytes(balls[1], balls[2], 4 * 16), 32, 8,
"battle/hud/balls.png", true)
hud.balls = "assets/generated/battle/hud/balls.png"
hud.ballsFirstTile = 0x31
-- "HP:" and the bar cells, as a plain 2bpp sheet rather than the ink-on-
-- transparent font page: the bar's rule is shade 3 (black) while its fill is
-- shade 1/2 (the HP colour), so flattening it to one ink loses the very
+93 -3
View File
@@ -1224,6 +1224,7 @@ function RomImporter.new(onComplete, opts)
forceImport = opts.forceImport or false,
onEditSave = opts.onEditSave,
onEditTouchControls = opts.onEditTouchControls,
onOpenSkinStudio = opts.onOpenSkinStudio,
isNX = isNX,
romImportMode = romImportMode,
mobileFileBridge = mobileFileBridge,
@@ -1784,7 +1785,12 @@ function RomImporter:filedropped(file)
-- readDroppedFile does here.
local name = file:getFilename() or ""
if name:lower():match("%.zip$") then
self:_installMod(file)
-- On the SKINS tab a zip is a skin; everywhere else it is a mod archive.
if self.tab == "skins" then
self:_installSkinZip(file)
else
self:_installMod(file)
end
return
end
-- A dropped .sav is a battery save: import it to a new slot for the active
@@ -2621,7 +2627,7 @@ function RomImporter:resumeAfterOverlay()
end
function RomImporter:_cycleTab(delta)
local order = { "red", "blue", "yellow", "gold", "mods", "find" }
local order = { "red", "blue", "yellow", "gold", "mods", "find", "skins" }
local idx = 1
for i, id in ipairs(order) do
if id == self.tab then idx = i; break end
@@ -2987,11 +2993,78 @@ function RomImporter:_switchTab(id)
self.tab = id
self._findSearchFocus = false
self:_disarmTextInput()
-- the skins list is cheap and can change behind the launcher's back
-- (an export, a hand-dropped folder), so re-read it on every visit
if id == "skins" then self:_ensureSkins(true) end
if GameVersion.VERSIONS[id] then
self:_setModScope(id)
end
end
-- ------- skins tab (touch skins + the desktop Skin Studio)
function RomImporter:_ensureSkins(force)
if self._skins and not force then return self._skins end
local TouchSkin = require("src.core.TouchSkin")
local out = {}
for _, entry in ipairs(TouchSkin.list()) do
local skin = TouchSkin.load(entry.root, entry.id)
local page = skin and skin.pages[1]
local controls = 0
for _, ctl in ipairs(page and page.controls or {}) do
if not ctl.decorative then controls = controls + 1 end
end
out[#out + 1] = {
id = entry.id,
source = entry.source,
pages = skin and #skin.pages or 0,
controls = controls,
screen = page ~= nil and page.viewport ~= nil,
ok = skin ~= nil,
}
end
self._skins = out
return out
end
function RomImporter:_activeSkin()
local opts = require("src.core.SaveData").loadOptions()
local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
return tc.skin
end
function RomImporter:_useSkin(id)
local SaveData = require("src.core.SaveData")
local opts = SaveData.loadOptions()
local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
tc.enabled = true
tc.skin = id
opts.touchControls = tc
SaveData.saveOptions(opts)
self._skinNotice = {
ok = true,
text = id and ("Now using " .. id) or "Now using the built-in pad",
}
end
function RomImporter:_installSkinZip(file)
local TouchSkin = require("src.core.TouchSkin")
local name = file:getFilename() or ""
local data, readError = readDroppedFile(file)
if not data then
self._skinNotice = { ok = false,
text = "Could not read the dropped file: " .. tostring(readError) }
return
end
local id, err = TouchSkin.installArchive(name, data)
self:_ensureSkins(true)
if not id then
self._skinNotice = { ok = false, text = "Import failed: " .. tostring(err) }
return
end
self._skinNotice = { ok = true, text = "Imported " .. id }
end
function RomImporter:_toggleFindSearchFocus()
self._findSearchFocus = not self._findSearchFocus
if self._findSearchFocus then
@@ -3018,6 +3091,13 @@ function RomImporter:_openSettings()
self.onEditTouchControls(version)
end
end
if self.onOpenSkinStudio then
local version = self.tab
hooks.openSkinStudio = function(skinId)
self:_closeSettings()
self.onOpenSkinStudio(version, skinId)
end
end
-- The tab the gear was opened on decides the row set: Gold reads a
-- different option block entirely, and offering it Gen 1's rows meant a
-- dozen controls that changed nothing (see LauncherSettings.gen2Rows).
@@ -3514,7 +3594,17 @@ function RomImporter:_setAllMods(want, confirmed)
local LauncherMods = require("src.mods.LauncherMods")
local ids, experimental = {}, false
for _, m in ipairs(self.mods or {}) do
if m.enabled ~= want then
local mismatched = m.enabled ~= want
if not self.modScope and type(m.enabledByVersion) == "table" then
mismatched = false
for _, game in ipairs(GameVersion.ORDER) do
if m.enabledByVersion[game] ~= want then
mismatched = true
break
end
end
end
if mismatched then
ids[#ids + 1] = m.id
if want and m.experimental then experimental = true end
end
+31 -2
View File
@@ -68,14 +68,20 @@ end
-- Acquisition-ordered id list (wBagItems). Rebuilt sorted once for
-- saves from before the order existed, then maintained incrementally.
function Bag.order(save)
function Bag.order(save, data)
local order = save.bagOrder
if not order then
local items = (data or require("src.core.Data")).items
order = {}
for id in pairs(save.inventory) do
if not isBadge(id) then table.insert(order, id) end
end
table.sort(order)
table.sort(order, function(a, b)
local ia = (items and items[a] and items[a].index) or math.huge
local ib = (items and items[b] and items[b].index) or math.huge
if ia ~= ib then return ia < ib end
return a < b
end)
save.bagOrder = order
end
-- drop stale ids, append unknown ones (defensive against direct
@@ -95,6 +101,29 @@ function Bag.order(save)
return order
end
-- engine/items/switch_items.asm:38 SwitchItemsInBag .below / .above -- the
-- rotate the PACK's SELECT performs, over the rows of ONE pocket.
function Bag.move(save, id, pocket, toIndex, data)
local order = Bag.order(save, data)
local slots, ids = {}, {}
for i = 1, #order do
if pocketOf(order[i], data) == pocket then
slots[#slots + 1] = i
ids[#ids + 1] = order[i]
end
end
local from
for i = 1, #ids do
if ids[i] == id then from = i break end
end
if not from then return false end
local to = math.max(1, math.min(math.floor(tonumber(toIndex) or from), #ids))
if to == from then return false end
table.insert(ids, to, table.remove(ids, from))
for i = 1, #slots do order[slots[i]] = ids[i] end
return true
end
-- Add qty of an item; returns false (and adds nothing) when a new slot
-- is needed and the bag is full, or when the stack would pass 99
-- (AddItemToInventory's per-slot quantity cap).
+29 -16
View File
@@ -15,6 +15,7 @@ local Runtime = require("src.mods.Runtime")
local GameViewport = require("src.render.GameViewport")
-- leaf module (no renderer dependency), so requiring it here cannot cycle
local FaithfulRes = require("src.core.FaithfulRes")
local TouchSkin = require("src.core.TouchSkin")
local Renderer = {}
@@ -84,7 +85,13 @@ local function displayMetrics()
end
if dpiX < 1e-6 then dpiX = 1 end
if dpiY < 1e-6 then dpiY = 1 end
return ww, wh, pw, ph, dpiX, dpiY
local vx, vy = 0, 0
local sx, sy, sw, sh = TouchSkin.viewport(pw, ph)
if sw and sw >= 1 and sh >= 1 then
vx, vy = math.floor(sx), math.floor(sy)
pw, ph = math.floor(sw), math.floor(sh)
end
return ww, wh, pw, ph, dpiX, dpiY, vx, vy
end
function Renderer:init()
@@ -274,6 +281,10 @@ function Renderer:worldViewSize()
-- this is the same sum with the viewport standing in for the window, so
-- both platforms show the same map area at the same zoom.
local cap = FaithfulRes.scaleCap()
if not cap and TouchSkin.hasViewport() then
local page = TouchSkin.page()
if not page.viewportExpand then cap = self:fitScale() end
end
if cap then
local uiw, uih = self:uiSize()
pw, ph = uiw * cap, uih * cap
@@ -745,7 +756,9 @@ end
-- presented through the GBC FX shader as a final pass.
function Renderer:endFrame(zones, worldZones)
GameViewport.setTarget()
local ww, wh, pw, ph, dpiX, dpiY = displayMetrics()
local ww, wh, pw, ph, dpiX, dpiY, vx, vy = displayMetrics()
local vux, vuy = vx / dpiX, vy / dpiY
local vuw, vuh = pw / dpiX, ph / dpiY
-- Sp = integer framebuffer pixels per GB pixel;
-- Sx/Sy = LOVE-unit draw scales (may differ when dpiX ≠ dpiY).
local Sp = self:fitScale()
@@ -753,8 +766,8 @@ function Renderer:endFrame(zones, worldZones)
local uiw, uih = self:uiSize()
local vpw, vph = uiw * Sx, uih * Sy
-- Snap the letterbox origin to a framebuffer pixel, then convert to units.
local ox = math.floor((pw - uiw * Sp) / 2) / dpiX
local oy = math.floor((ph - uih * Sp) / 2) / dpiY
local ox = (vx + math.floor((pw - uiw * Sp) / 2)) / dpiX
local oy = (vy + math.floor((ph - uih * Sp) / 2)) / dpiY
-- The UI has its own scale: it steps down as the survey zoom goes out (see
-- uiScale), so it can be smaller than the world letterbox. Un-zoomed these
-- are identical to Sp/ox/oy and every rect below is what it always was.
@@ -770,8 +783,8 @@ function Renderer:endFrame(zones, worldZones)
end
local Ux, Uy = Up / dpiX, Up / dpiY
local uvpw, uvph = uiw * Ux, uih * Uy
local uox = math.floor((pw - uiw * Up) / 2) / dpiX
local uoy = math.floor((ph - uih * Up) / 2) / dpiY
local uox = (vx + math.floor((pw - uiw * Up) / 2)) / dpiX
local uoy = (vy + math.floor((ph - uih * Up) / 2)) / dpiY
local GBCFX = require("src.render.GBCFX")
-- Forced mono/Classic modes still need a whole-screen zone when a state
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap.
@@ -921,7 +934,7 @@ function Renderer:endFrame(zones, worldZones)
-- skipped entirely (nothing drew into it). The UI blit below still
-- runs, so dialogs, menus and the HUD sit on top as usual.
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setScissor(0, 0, ww, wh)
love.graphics.setScissor(vux, vuy, vuw, vuh)
local loveMajor = love.getVersion()
if love.system and love.system.getOS and love.system.getOS() == "iOS" and loveMajor >= 12 then
love.graphics.draw(self.worldOverride, 0, wh, 0, 1 / dpiX, -1 / dpiY)
@@ -933,7 +946,7 @@ function Renderer:endFrame(zones, worldZones)
local fade = self.worldFadeAlpha
if fade and fade > 0 then
love.graphics.setColor(0, 0, 0, fade)
love.graphics.rectangle("fill", 0, 0, ww, wh)
love.graphics.rectangle("fill", vux, vuy, vuw, vuh)
love.graphics.setColor(1, 1, 1, 1)
end
elseif self.worldActive then
@@ -941,8 +954,8 @@ function Renderer:endFrame(zones, worldZones)
local sx, sy = sp / dpiX, sp / dpiY
local wvw = self.worldCanvas:getWidth()
local wvh = self.worldCanvas:getHeight()
local wox = math.floor((pw - wvw * sp) / 2) / dpiX
local woy = math.floor((ph - wvh * sp) / 2) / dpiY
local wox = (vx + math.floor((pw - wvw * sp) / 2)) / dpiX
local woy = (vy + math.floor((ph - wvh * sp) / 2)) / dpiY
-- Tilt mode projects the ground world pass through the perspective mesh
-- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone
-- scissoring here). drawTiltedWorld returns false when tilt is off or
@@ -953,9 +966,9 @@ function Renderer:endFrame(zones, worldZones)
Tilt.active() and self:drawTiltedWorld(worldZones or zones, sx, sy, wox, woy, present)
if not projected then
if worldZones then
blit(self.worldCanvas, sx, sy, worldZones, sx, sy, wox, woy, 0, 0, ww, wh)
blit(self.worldCanvas, sx, sy, worldZones, sx, sy, wox, woy, vux, vuy, vuw, vuh)
else
blit(self.worldCanvas, sx, sy, zones, Sx, Sy, wox, woy, 0, 0, ww, wh)
blit(self.worldCanvas, sx, sy, zones, Sx, Sy, wox, woy, vux, vuy, vuw, vuh)
end
-- OBP-baked overworld sprites replay on top of the zone pass (GBC
-- mode per-object coloring; see PaletteFX.markSpriteRedraw). Grass
@@ -964,7 +977,7 @@ function Renderer:endFrame(zones, worldZones)
local redraws = PaletteFX.spriteRedraws()
if redraws[1] then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setScissor(0, 0, ww, wh)
love.graphics.setScissor(vux, vuy, vuw, vuh)
local activeShader = nil
for _, r in ipairs(redraws) do
local wanted = r.colors
@@ -996,7 +1009,7 @@ function Renderer:endFrame(zones, worldZones)
if self.uprightActive then
local M = self.UPRIGHT_MARGIN
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setScissor(0, 0, ww, wh)
love.graphics.setScissor(vux, vuy, vuw, vuh)
love.graphics.draw(self.uprightCanvas, wox - M * sx, woy - M * sy, 0, sx, sy)
love.graphics.setScissor()
end
@@ -1007,7 +1020,7 @@ function Renderer:endFrame(zones, worldZones)
local fade = self.worldFadeAlpha
if fade and fade > 0 then
love.graphics.setColor(0, 0, 0, fade)
love.graphics.rectangle("fill", 0, 0, ww, wh)
love.graphics.rectangle("fill", vux, vuy, vuw, vuh)
love.graphics.setColor(1, 1, 1, 1)
end
end
@@ -1029,7 +1042,7 @@ function Renderer:endFrame(zones, worldZones)
-- (and its duplicate #772).
if self.battleDim and self.battleDim > 0 then
love.graphics.setColor(0, 0, 0, self.battleDim)
for _, r in ipairs(subtractRect({ { 0, 0, ww, wh } }, uox, uoy, uvpw, uvph)) do
for _, r in ipairs(subtractRect({ { vux, vuy, vuw, vuh } }, uox, uoy, uvpw, uvph)) do
love.graphics.rectangle("fill", r[1], r[2], r[3], r[4])
end
love.graphics.setColor(1, 1, 1, 1)
+4
View File
@@ -26,6 +26,7 @@ local NAME_DELAYS = { FAST = 1, MID = 3, SLOW = 5 }
-- up over the still-visible text (YesNoChoicePokeCenter and friends);
-- the box then closes and choice(yes) runs instead of onDone.
-- opts.defaultNo starts the cursor on NO.
-- opts.choiceLabels / opts.choiceBox: data/yes_no_menu_strings.asm:16
-- opts.auto: texts with no `prompt` (a text_asm/text_end tail, like
-- _UsedStrengthText) never wait for a button: once the last page has
-- typed out, auto.sound() runs (returning an audio source blocks like
@@ -51,6 +52,8 @@ function TextBox.new(game, text, onDone, opts)
self.choice = opts and opts.choice
self.defaultNo = opts and opts.defaultNo
self.choiceNoSound = opts and opts.noSound
self.choiceLabels = opts and opts.choiceLabels
self.choiceBox = opts and opts.choiceBox
self.money = opts and opts.money
self.auto = opts and opts.auto
self.stay = opts and opts.stay
@@ -340,6 +343,7 @@ function TextBox:update(dt)
self.game.stack:pop() -- this text box, under the choice
self.choice(yes)
end, { defaultNo = self.defaultNo, noSound = self.choiceNoSound,
labels = self.choiceLabels, box = self.choiceBox,
-- this box is anchored below it; the pair moves together
anchor = "bottom" }))
end
+14
View File
@@ -134,6 +134,10 @@ function Commands.show_text(ctx, textId, subs, extraOpts)
opts[k] = v
end
end
-- MONEY_BOX (engine/menus/text_box.asm:133) reads the live wallet
if opts and opts.money == true then
opts.money = function() return ctx.save.money end
end
ctx.game.stack:push(TextBox.new(ctx.game, text, function()
runner:resume()
end, opts))
@@ -780,6 +784,16 @@ function Commands.give_money(ctx, amount)
ctx.save.money = math.max(0, ctx.save.money + amount)
end
-- check_money <amount>: HasEnoughMoney (home/money.asm:1)
function Commands.check_money(ctx, amount)
ctx.lastCheck = (ctx.save.money or 0) >= (amount or 0)
end
-- take_money <amount>: SubBCDPredef (engine/math/bcd.asm:193)
function Commands.take_money(ctx, amount)
ctx.save.money = math.max(0, (ctx.save.money or 0) - (amount or 0))
end
-- Point LAST_MAP exits at an outdoor door (pokered wLastMap). Keeps the
-- live overworld memory in sync so a scripted home warp from the HoF PC
-- does not leave Red's house mats aimed at Indigo Plateau (#103).
+5 -4
View File
@@ -300,7 +300,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
end
list.index = math.min(list.index, math.max(1, #list.items))
if extra and extra.evolveTo then
list:close()
-- engine/menus/start_sub_menus.asm:408 .useItem_partyMenu
local Evolution = require("src.pokemon.Evolution")
-- item_effects.asm ItemUseEvoStone sets wForceEvolution before
-- TryEvolvingMon, so a stone evolution's B press is read and
@@ -428,11 +428,12 @@ local function pickTargetAndUse(game, battle, id, list)
-- TM/HM: open the party menu in Gen 1's TM/HM display mode so each mon
-- shows ABLE / NOT ABLE from its learnset and the prompt reads "Use TM on
-- which POKeMON?" (engine/items/item_effects.asm ItemUseTMHM ->
-- party_menu.asm TM/HM type). Stones and other pickOnly items keep the
-- plain HP layout (Gen 1 shows no ABLE/NOT ABLE for them), so gate
-- strictly on def.machine. #210
-- party_menu.asm TM/HM type). #210 Stones get the same ABLE / NOT ABLE
-- column: ItemUseEvoStone sets EVO_STONE_PARTY_MENU (party_menu.asm:114).
if def and def.machine then
opts.tmhm = { move = def.machine.move, kind = def.machine.kind }
elseif ItemEffects.isStone(id) then
opts.evoStone = id
end
require("src.ui.Screens").push(game, "PartyMenu", opts)
end
+10 -4
View File
@@ -23,11 +23,15 @@ function ChoiceBox.new(game, onChoose, opts)
-- and docking it to the window edge instead tears it off that screen by
-- however far the letterbox sits from the edge.
self.anchor = opts and opts.anchor or nil
local box = Theme.choiceBox
local box = (opts and opts.box) or Theme.choiceBox
self.tx = (opts and opts.tx) or box.tx
self.ty = (opts and opts.ty) or box.ty
self.tw = (opts and opts.tw) or box.tw
self.th = (opts and opts.th) or box.th
-- TwoOptionMenuStrings rows carry their own labels and a "blank line
-- before first menu item?" flag (data/yes_no_menu_strings.asm:8-16)
self.labels = (opts and opts.labels) or { "YES", "NO" }
self.firstItem = (opts and opts.firstItem) or box.firstItem or 1
return self
end
@@ -81,10 +85,12 @@ function ChoiceBox:draw()
local paper = self.game and self.game.textboxPaper and self.game:textboxPaper()
Font.drawBox(tx, ty, tw, th, paper)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("YES"), (tx + 2) * 8, (ty + 1) * 8)
Font.draw(Strings("NO"), (tx + 2) * 8, (ty + 3) * 8)
-- <NEXT> advances 2 * SCREEN_WIDTH -- home/text.asm:64
local row = self.firstItem
Font.draw(Strings(self.labels[1]), (tx + 2) * 8, (ty + row) * 8)
Font.draw(Strings(self.labels[2]), (tx + 2) * 8, (ty + row + 2) * 8)
Font.drawCode(Theme.cursor, (tx + 1) * 8,
(ty + (self.index == 1 and 1 or 3)) * 8)
(ty + row + (self.index == 1 and 0 or 2)) * 8)
love.graphics.setColor(1, 1, 1, 1)
end
+15 -8
View File
@@ -293,23 +293,30 @@ function IntroMovie:update(dt)
return
end
local input = self.game.input
if input:wasPressed("a") or input:wasPressed("start") then
-- CheckForUserInterruption (home/overworld.asm:2395) returns carry only
-- on a fresh START or A -- B alone never skips the intro.
-- PlayIntro still GBFadeOutToWhite's after an interrupted scene; the
-- white hold stands in for that beat before the title is built.
self:exitToTitle()
return
end
-- CheckForUserInterruption (home/overworld.asm:2395) returns carry only
-- on a fresh START or A -- B alone never skips the intro.
local skip = input:wasPressed("a") or input:wasPressed("start")
self.timer = self.timer + 1
if self.phase == 1 then
-- the copyright card is a bare DelayFrames, deaf to input (intro.asm:311)
if self.timer >= COPYRIGHT_FRAMES then self:startPhase(2) end
elseif self.phase == 2 then
if self.timer == STAR_START then
Sound.play(self.game.data, "Shooting_Star") -- splash.asm:29-30
end
-- intro.asm:325 `jr c, .next`
if skip and self.timer >= STAR_START and self.timer < WAVES_END then
self:startPhase(3)
return
end
if self.timer >= SPLASH_FRAMES then self:startPhase(3) end
else
-- PlayIntro still GBFadeOutToWhite's after an interrupted scene; the
-- white hold stands in for that beat before the title is built.
if skip then
self:exitToTitle()
return
end
-- PlayShootingStar ends `jp Delay3` once Music_IntroBattle is playing
-- (intro.asm:337), so PlayIntroScene's first op is not on the music's
-- own frame
+17
View File
@@ -313,6 +313,9 @@ function PartyMenu.new(game, opts)
-- TM/HM display (ABLE / NOT ABLE per mon instead of the HP bar, and the
-- "Use TM on which POKeMON?" prompt). Set by BagMenu.pickTargetAndUse. #210
self.tmhm = opts.tmhm
-- Evolution stones: opts.evoStone = item id gives Gen 1's
-- EVO_STONE_PARTY_MENU ABLE / NOT ABLE display (party_menu.asm:114). #1411
self.evoStone = opts.evoStone
self.forceSwitch = opts.forceSwitch
self.battle = opts.battle
self.party = party -- link/scoped battles pass their local party view
@@ -800,6 +803,20 @@ function PartyMenu:draw()
else
Font.draw(Strings("NOT ABLE"), 88, y + 8)
end
elseif self.evoStone then
-- party_menu.asm:114 .evolutionStoneMenu: an EVOLVE_ITEM row matching
-- wEvoStoneItemID, printed in the TM/HM strings' row+1 column+9 slot
local can = false
for _, evo in ipairs(def.evolutions or {}) do
if evo.method == "ITEM" and evo.item == self.evoStone then
can = true break
end
end
if can then
Font.draw(Strings("ABLE"), 120, y + 8)
else
Font.draw(Strings("NOT ABLE"), 88, y + 8)
end
else
if mon.hp <= 0 then
Font.draw(Strings("FNT"), 136, y)
+926
View File
@@ -0,0 +1,926 @@
local Kit = require("src.ui.kit.Kit")
local Theme = require("src.ui.kit.Theme")
local PAL = Theme.PAL
local TouchSkin = require("src.core.TouchSkin")
local TouchControls = require("src.core.TouchControls")
local SaveData = require("src.core.SaveData")
local Studio = {}
Studio.CANVASES = {
{ id = "phone_portrait", label = "Phone portrait", w = 1080, h = 1920 },
{ id = "phone_landscape", label = "Phone landscape", w = 1920, h = 1080 },
{ id = "tablet_portrait", label = "Tablet portrait", w = 1536, h = 2048 },
{ id = "tablet_landscape", label = "Tablet landscape", w = 2048, h = 1536 },
{ id = "steamdeck", label = "Steam Deck", w = 1280, h = 800 },
{ id = "desktop_1080", label = "Desktop 1080p", w = 1920, h = 1080 },
{ id = "ultrawide", label = "Ultrawide 21:9", w = 2560, h = 1080 },
{ id = "sgb_border", label = "Super Game Boy border", w = 256, h = 224,
lockViewport = { x = 48 / 256, y = 40 / 224, w = 160 / 256, h = 144 / 224 } },
}
local HANDLE = 7
local HANDLES = {
{ "nw", 0, 0 }, { "n", 0.5, 0 }, { "ne", 1, 0 },
{ "w", 0, 0.5 }, { "e", 1, 0.5 },
{ "sw", 0, 1 }, { "s", 0.5, 1 }, { "se", 1, 1 },
}
local GB_ASPECT = 160 / 144
local function clamp01(v)
if v ~= v then return 0 end
if v < 0 then return 0 end
if v > 1 then return 1 end
return v
end
local function round(v) return math.floor(v + 0.5) end
function Studio.canvas()
return Studio.CANVASES[Studio.canvasIndex] or Studio.CANVASES[1]
end
function Studio.page()
local skin = Studio.skin
if not skin then return nil end
return skin.pages[Studio.pageIndex] or skin.pages[1]
end
function Studio.selectedControl()
local page = Studio.page()
if not page then return nil end
return page.controls[Studio.selected]
end
local function markDirty()
Studio.dirty = true
Studio.status = nil
end
local function syncActive()
TouchSkin.setActive(Studio.skin)
TouchSkin.pageIndex = Studio.pageIndex
end
function Studio.setCanvas(index)
local n = #Studio.CANVASES
Studio.canvasIndex = ((index - 1) % n) + 1
local canvas = Studio.canvas()
local page = Studio.page()
if page and canvas.lockViewport then
page.viewport = {
x = canvas.lockViewport.x, y = canvas.lockViewport.y,
w = canvas.lockViewport.w, h = canvas.lockViewport.h,
}
page.viewportFill = false
markDirty()
end
if page then page.aspect = canvas.w / canvas.h end
end
function Studio.load(opts)
opts = opts or {}
Studio.onClose = opts.onClose
Studio.onPlay = opts.onPlay
Studio.version = opts.version
Studio.pageIndex = 1
Studio.selected = nil
Studio.testing = false
Studio.dirty = false
Studio.status = nil
Studio.drag = nil
Studio.pendingPlay = false
Studio.canvasIndex = 1
Studio.aspectLock = true
Studio.skinIdField = ""
Studio.available = TouchSkin.list()
Studio.imageTarget = "idle"
TouchControls:init()
TouchControls.active = true
TouchControls.enabled = true
TouchControls:setPreview(true)
local start = opts.skinId
if not start then
local saved = SaveData.loadOptions()
local tc = type(saved.touchControls) == "table" and saved.touchControls or {}
start = tc.skin
end
if start and TouchSkin.find(start) then
Studio.open(start)
else
Studio.skin = TouchSkin.newSkin("new_skin")
Studio.skinIdField = Studio.skin.id
syncActive()
end
end
function Studio.open(id)
local entry = TouchSkin.find(id)
if not entry then return false end
local loaded = TouchSkin.load(entry.root, entry.id)
if not loaded then return false end
Studio.skin = TouchSkin.clone(loaded)
Studio.skinIdField = Studio.skin.id
Studio.pageIndex = 1
Studio.selected = nil
Studio.dirty = false
Studio.images = TouchSkin.listImages(Studio.skin.root)
syncActive()
return true
end
function Studio.unload()
Studio.pendingPlay = false
TouchSkin.setSurface(nil)
TouchSkin.setActive(nil)
TouchControls:setPreview(false)
TouchControls:reset()
Studio.skin = nil
Studio.onClose = nil
Studio.onPlay = nil
Studio.drag = nil
end
-- --------------------------------------------------------------- editing
function Studio.addControl()
local page = Studio.page()
if not page then return end
page.controls[#page.controls + 1] =
TouchSkin.newControl("a", 0.5, 0.5, 0.16, 0.09, "radial")
Studio.selected = #page.controls
markDirty()
end
function Studio.deleteControl()
local page = Studio.page()
if not page or not Studio.selected then return end
table.remove(page.controls, Studio.selected)
Studio.selected = page.controls[Studio.selected] and Studio.selected
or (#page.controls > 0 and #page.controls or nil)
markDirty()
end
function Studio.duplicateControl()
local page, ctl = Studio.page(), Studio.selectedControl()
if not page or not ctl then return end
local copy = TouchSkin.newControl(ctl.spec, clamp01(ctl.x + 0.04),
clamp01(ctl.y + 0.04), ctl.rangeX * 2, ctl.rangeY * 2, ctl.shape)
copy.imagePath, copy.image = ctl.imagePath, ctl.image
copy.pressedImagePath, copy.pressedImage = ctl.pressedImagePath, ctl.pressedImage
copy.rangeMod, copy.alphaMod = ctl.rangeMod, ctl.alphaMod
table.insert(page.controls, Studio.selected + 1, copy)
Studio.selected = Studio.selected + 1
markDirty()
end
function Studio.cycleBind(dir)
local ctl = Studio.selectedControl()
if not ctl then return end
local list, at = TouchSkin.BINDS, 1
for i, spec in ipairs(list) do
if spec == ctl.spec then at = i break end
end
TouchSkin.setBind(ctl, list[((at - 1 + dir) % #list) + 1])
markDirty()
end
function Studio.cycleImage(dir)
local ctl = Studio.selectedControl()
local page = Studio.page()
if not page then return end
Studio.images = Studio.images or TouchSkin.listImages(Studio.skin.root)
local list = Studio.images
local field = Studio.imageTarget
local owner = (field == "bezel") and page or ctl
if not owner then return end
local key = (field == "pressed") and "pressedImagePath" or "imagePath"
if field == "bezel" then key = "imagePath" end
local at = 0
for i, rel in ipairs(list) do
if rel == owner[key] then at = i break end
end
local next_ = at + dir
if next_ < 0 then next_ = #list end
if next_ > #list then next_ = 0 end
local rel = (next_ >= 1) and list[next_] or nil
owner[key] = rel
local img = rel and TouchSkin.resolveImage(Studio.skin.root, rel) or nil
if field == "pressed" then owner.pressedImage = img else owner.image = img end
markDirty()
end
function Studio.assignImage(rel)
local page, ctl = Studio.page(), Studio.selectedControl()
local img = TouchSkin.resolveImage(Studio.skin.root, rel)
if ctl and Studio.imageTarget == "pressed" then
ctl.pressedImagePath, ctl.pressedImage = rel, img
elseif ctl and Studio.imageTarget == "idle" then
ctl.imagePath, ctl.image = rel, img
elseif page then
page.imagePath, page.image = rel, img
end
Studio.images = TouchSkin.listImages(Studio.skin.root)
Studio.dirty = true
end
function Studio.filedropped(file)
if not Studio.skin then return end
local path = (file.getFilename and file:getFilename()) or ""
local base = path:match("([^/\\]+)$") or path
if not base:lower():match("%.png$") and not base:lower():match("%.jpe?g$") then
Studio.status = "Drop a PNG or JPG to use it as art."
return
end
local ok, data = pcall(function()
file:open("r")
local bytes = file:read()
file:close()
return bytes
end)
if not ok or not data then
Studio.status = "Could not read " .. base
return
end
local rel, err = TouchSkin.importImage(Studio.skin, base, data)
if not rel then
Studio.status = "Import failed: " .. tostring(err)
return
end
Studio.assignImage(rel)
local where = Studio.selectedControl()
and (Studio.imageTarget == "pressed" and "pressed art" or "idle art")
or "bezel"
Studio.status = "Imported " .. rel .. " as " .. where
Studio.skinIdField = Studio.skin.id
end
function Studio.detectViewport()
local page = Studio.page()
if not page or not Studio.skin then return end
if Studio.canvas().lockViewport then
Studio.status = "This preset locks the screen position."
return
end
if not page.imagePath then
Studio.status = "Pick a bezel image first."
return
end
local rect, pw, ph = TouchSkin.detectViewport(Studio.skin.root, page.imagePath)
if not rect then
Studio.status = "No transparent screen hole found in " .. page.imagePath
return
end
page.viewport = rect
Studio.status = ("Screen detected: %dx%d px in the bezel art"):format(pw, ph)
Studio.dirty = true
end
function Studio.toggleViewport()
local page = Studio.page()
if not page then return end
if Studio.canvas().lockViewport then return end
if page.viewport then
page.viewport = nil
else
page.viewport = { x = 0.1, y = 0.05, w = 0.8, h = 0.45 }
end
markDirty()
end
function Studio.addPage()
local skin = Studio.skin
if not skin then return end
local page = TouchSkin.newSkin(skin.id).pages[1]
page.name = "page" .. (#skin.pages + 1)
page.index = #skin.pages + 1
skin.pages[#skin.pages + 1] = page
Studio.pageIndex = #skin.pages
Studio.selected = nil
syncActive()
markDirty()
end
function Studio.save()
local skin = Studio.skin
if not skin then return end
local id = (Studio.skinIdField or ""):gsub("[^%w_%-]", "")
if id == "" then
Studio.status = "Give the skin a name first."
return
end
local dest, failed = TouchSkin.saveTo(skin, id)
if not dest then
Studio.status = "Save failed: " .. tostring(failed)
return
end
Studio.dirty = false
Studio.available = TouchSkin.list()
Studio.status = "Saved to " .. dest
if type(failed) == "table" and failed[1] then
Studio.status = Studio.status .. " (" .. #failed .. " image(s) not found)"
end
end
function Studio.export()
local skin = Studio.skin
if not skin then return end
if Studio.dirty then Studio.save() end
local path, missing = TouchSkin.export(skin)
if not path then
Studio.status = "Export failed: " .. tostring(missing)
return
end
Studio.status = "Exported " .. path
Studio.available = TouchSkin.list()
end
function Studio.play()
local skin = Studio.skin
if not skin then return end
Studio.save()
if Studio.dirty then return end
local opts = SaveData.loadOptions()
local block = type(opts.touchControls) == "table" and opts.touchControls or {}
block.enabled = true
block.skin = skin.id
opts.touchControls = block
SaveData.saveOptions(opts)
-- Handing off here would unload the studio inside its own draw pass and
-- leave the rest of this frame drawing against a dead skin; Studio.update
-- runs it on the next tick instead.
Studio.pendingPlay = true
Studio.status = "Starting the game with " .. skin.id .. "..."
end
-- ---------------------------------------------------------------- canvas
function Studio.canvasRect(x, y, w, h)
local canvas = Studio.canvas()
local aspect = canvas.w / canvas.h
local cw, ch = w, w / aspect
if ch > h then ch, cw = h, h * aspect end
return x + (w - cw) * 0.5, y + (h - ch) * 0.5, cw, ch
end
local function controlRect(page, ctl, r)
local cx, cy, halfW, halfH =
TouchSkin.controlGeometry(page, ctl, r.w, r.h, r.x, r.y)
return cx - halfW, cy - halfH, halfW * 2, halfH * 2
end
local function viewportRect(page, r)
local v = page.viewport
if not v then return nil end
return r.x + v.x * r.w, r.y + v.y * r.h, v.w * r.w, v.h * r.h
end
local function handleRects(bx, by, bw, bh)
local out = {}
for _, h in ipairs(HANDLES) do
out[#out + 1] = {
id = h[1],
x = bx + bw * h[2] - HANDLE * Kit.scale,
y = by + bh * h[3] - HANDLE * Kit.scale,
w = HANDLE * 2 * Kit.scale, h = HANDLE * 2 * Kit.scale,
}
end
return out
end
local function applyResize(id, bx, by, bw, bh, dx, dy)
if id:find("w") then bx = bx + dx bw = bw - dx end
if id:find("e") then bw = bw + dx end
if id:find("n") then by = by + dy bh = bh - dy end
if id:find("s") then bh = bh + dy end
return bx, by, math.max(4, bw), math.max(4, bh)
end
function Studio.beginCanvasDrag(mx, my, r)
local page = Studio.page()
if not page then return end
local ctl = Studio.selectedControl()
if ctl then
local bx, by, bw, bh = controlRect(page, ctl, r)
for _, h in ipairs(handleRects(bx, by, bw, bh)) do
if mx >= h.x and mx <= h.x + h.w and my >= h.y and my <= h.y + h.h then
Studio.drag = { kind = "control-resize", handle = h.id, mx = mx, my = my,
bx = bx, by = by, bw = bw, bh = bh }
return
end
end
end
local vx, vy, vw, vh = viewportRect(page, r)
if vx and not Studio.canvas().lockViewport then
for _, h in ipairs(handleRects(vx, vy, vw, vh)) do
if mx >= h.x and mx <= h.x + h.w and my >= h.y and my <= h.y + h.h then
Studio.drag = { kind = "viewport-resize", handle = h.id, mx = mx, my = my,
bx = vx, by = vy, bw = vw, bh = vh }
Studio.selected = nil
return
end
end
end
for i = #page.controls, 1, -1 do
local c = page.controls[i]
local bx, by, bw, bh = controlRect(page, c, r)
if mx >= bx and mx <= bx + bw and my >= by and my <= by + bh then
Studio.selected = i
Studio.drag = { kind = "control-move", mx = mx, my = my,
bx = bx, by = by, bw = bw, bh = bh }
return
end
end
if vx and not Studio.canvas().lockViewport
and mx >= vx and mx <= vx + vw and my >= vy and my <= vy + vh then
Studio.selected = nil
Studio.drag = { kind = "viewport-move", mx = mx, my = my,
bx = vx, by = vy, bw = vw, bh = vh }
return
end
Studio.selected = nil
end
function Studio.updateDrag(mx, my, r)
local d = Studio.drag
local page = Studio.page()
if not d or not page then return end
local dx, dy = mx - d.mx, my - d.my
local bx, by, bw, bh = d.bx, d.by, d.bw, d.bh
if d.kind == "control-move" or d.kind == "viewport-move" then
bx, by = bx + dx, by + dy
else
bx, by, bw, bh = applyResize(d.handle, bx, by, bw, bh, dx, dy)
end
if d.kind:find("viewport") and Studio.aspectLock and d.handle then
-- Derive the axis the handle does not drive, or an edge handle fights the
-- lock and appears dead. A corner drives whichever way the pointer moved
-- furthest, so dragging it up/down resizes as readily as left/right.
local byHeight = (d.handle == "n" or d.handle == "s")
if not byHeight and #d.handle > 1 then
byHeight = math.abs(dy) > math.abs(dx)
end
if byHeight then
local newW = bh * GB_ASPECT
if d.handle:find("w") then bx = bx + (bw - newW)
elseif not d.handle:find("e") then bx = bx + (bw - newW) * 0.5 end
bw = newW
else
local newH = bw / GB_ASPECT
if d.handle:find("n") then by = by + (bh - newH)
elseif not d.handle:find("s") then by = by + (bh - newH) * 0.5 end
bh = newH
end
end
if d.kind:find("control") then
local ctl = Studio.selectedControl()
if not ctl then return end
ctl.x = clamp01(((bx + bw * 0.5) - r.x) / r.w)
ctl.y = clamp01(((by + bh * 0.5) - r.y) / r.h)
ctl.rangeX = math.max(0.002, (bw * 0.5) / r.w)
ctl.rangeY = math.max(0.002, (bh * 0.5) / r.h)
else
page.viewport = {
x = clamp01((bx - r.x) / r.w), y = clamp01((by - r.y) / r.h),
w = math.max(0.02, bw / r.w), h = math.max(0.02, bh / r.h),
}
end
markDirty()
end
-- ----------------------------------------------------------------- draw
local function drawCanvas(x, y, w, h)
local page = Studio.page()
local r = { }
r.x, r.y, r.w, r.h = Studio.canvasRect(x, y, w, h)
Studio.lastCanvas = r
if not page then return r end
Theme.fill(r.x, r.y, r.w, r.h, { 0, 0, 0 }, 1)
TouchSkin.setSurface(r.x, r.y, r.w, r.h)
syncActive()
local vx, vy, vw, vh = viewportRect(page, r)
if vx then
local scale = math.min(vw / 160, vh / 144)
local gw, gh = 160 * scale, 144 * scale
local gx, gy = vx + (vw - gw) * 0.5, vy + (vh - gh) * 0.5
Theme.fill(vx, vy, vw, vh, { 12, 12, 12 }, 1)
Theme.fill(gx, gy, gw, gh, { 155, 188, 15 }, 1)
Kit.textCenter("small", "160 x 144", gx, gy + gh * 0.5 - Kit.textHeight("small") * 0.5,
gw, { 20, 40, 20 })
end
TouchControls:draw()
TouchSkin.setSurface(nil)
Theme.strokeRounded(r.x, r.y, r.w, r.h, PAL.line, Theme.A.hairline, 1, 2)
if Studio.testing then return r end
if vx then
Theme.strokeRounded(vx, vy, vw, vh, PAL.blue, 0.9, 2, 2)
Kit.text("small", "SCREEN", vx + 4 * Kit.scale, vy + 4 * Kit.scale, PAL.blue)
if not Studio.canvas().lockViewport then
local a = Studio.selectedControl() and 0.45 or 1
for _, hd in ipairs(handleRects(vx, vy, vw, vh)) do
Theme.fill(hd.x, hd.y, hd.w, hd.h, PAL.blue, a)
end
end
end
for i, ctl in ipairs(page.controls) do
local bx, by, bw, bh = controlRect(page, ctl, r)
local selected = (i == Studio.selected)
local c = ctl.decorative and PAL.muted or (selected and PAL.green or PAL.line)
Theme.strokeRounded(bx, by, bw, bh, c, selected and 1 or 0.45,
selected and 2 or 1, ctl.shape == "radial" and bh * 0.5 or 2)
if selected then
for _, hd in ipairs(handleRects(bx, by, bw, bh)) do
Theme.fill(hd.x, hd.y, hd.w, hd.h, PAL.green, 1)
end
Kit.text("small", ctl.spec, bx, by - Kit.textHeight("small") - 2 * Kit.scale,
PAL.green)
end
end
return r
end
local function inspectorBody(x, y, w)
if not Studio.skin then return y end
local page = Studio.page()
local ctl = Studio.selectedControl()
local rowH = math.max(Kit.tapMin(), 30 * Kit.scale)
local gap = 6 * Kit.scale
local cy = y
Kit.caption(x, cy, "SKIN")
cy = cy + Kit.textHeight("small") + gap
Studio.skinIdField = Kit.textfield("skinid", x, cy, w, rowH,
Studio.skinIdField, "skin name")
cy = cy + rowH + gap
local third = (w - gap * 2) / 3
if Kit.button(x, cy, third, rowH, "Save", { id = "save" }) then Studio.save() end
if Kit.button(x + third + gap, cy, third, rowH, "Export", { id = "export" }) then
Studio.export()
end
if Kit.button(x + (third + gap) * 2, cy, third, rowH, "Play", { id = "play" }) then
Studio.play()
end
cy = cy + rowH + gap * 2
Kit.caption(x, cy, "OPEN")
cy = cy + Kit.textHeight("small") + gap
local half = (w - gap) / 2
if Kit.button(x, cy, half, rowH, "New", { id = "new" }) then
Studio.skin = TouchSkin.newSkin("new_skin")
Studio.skinIdField = "new_skin"
Studio.pageIndex, Studio.selected = 1, nil
Studio.images = {}
syncActive()
markDirty()
end
if Kit.button(x + half + gap, cy, half, rowH,
"Load " .. (#Studio.available > 0 and "\226\150\184" or "-"),
{ id = "load", enabled = #Studio.available > 0 }) then
Studio.loadIndex = ((Studio.loadIndex or 0) % #Studio.available) + 1
Studio.open(Studio.available[Studio.loadIndex].id)
end
cy = cy + rowH + gap * 2
Kit.caption(x, cy, "PAGE " .. Studio.pageIndex .. " / " .. #(Studio.skin.pages or {}))
cy = cy + Kit.textHeight("small") + gap
if Kit.button(x, cy, half, rowH, "Next page", { id = "pagenext" }) then
Studio.pageIndex = (Studio.pageIndex % #Studio.skin.pages) + 1
Studio.selected = nil
syncActive()
end
if Kit.button(x + half + gap, cy, half, rowH, "Add page", { id = "pageadd" }) then
Studio.addPage()
end
cy = cy + rowH + gap
if page then
local bezel = page.imagePath or "(none)"
if Kit.button(x, cy, w, rowH, "Bezel: " .. bezel, { id = "bezel" }) then
Studio.imageTarget = "bezel"
Studio.cycleImage(1)
end
cy = cy + rowH + gap
local vpLabel = page.viewport and "Screen cutout: ON" or "Screen cutout: OFF"
if Kit.button(x, cy, half, rowH, vpLabel, { id = "vp",
enabled = not Studio.canvas().lockViewport }) then
Studio.toggleViewport()
end
Studio.aspectLock = Kit.checkbox(x + half + gap, cy, half, rowH,
Studio.aspectLock, "10:9 lock", "aspect")
cy = cy + rowH + gap
if Kit.button(x, cy, w, rowH, "Detect screen from bezel", { id = "detect",
enabled = page.imagePath ~= nil and not Studio.canvas().lockViewport }) then
Studio.detectViewport()
end
cy = cy + rowH + gap * 2
end
Kit.caption(x, cy, "CONTROLS")
cy = cy + Kit.textHeight("small") + gap
if Kit.button(x, cy, third, rowH, "Add", { id = "add" }) then Studio.addControl() end
if Kit.button(x + third + gap, cy, third, rowH, "Dup",
{ id = "dup", enabled = ctl ~= nil }) then
Studio.duplicateControl()
end
if Kit.button(x + (third + gap) * 2, cy, third, rowH, "Del",
{ id = "del", enabled = ctl ~= nil }) then
Studio.deleteControl()
end
cy = cy + rowH + gap
if not ctl then
Kit.textWrapped("small", page and #page.controls == 0
and "No controls yet. Add one, then drag it on the canvas."
or "Click a control on the canvas to edit it.", x, cy, w, PAL.muted, 3)
return cy + Kit.textHeight("small") * 3
end
local canvas = Studio.canvas()
if Kit.button(x, cy, w, rowH, "Bind: " .. ctl.spec, { id = "bind" }) then
Studio.cycleBind(1)
end
cy = cy + rowH + 2 * Kit.scale
Kit.text("small", TouchSkin.describeBind(ctl.spec), x, cy, PAL.muted)
cy = cy + Kit.textHeight("small") + gap
if Kit.button(x, cy, half, rowH, "Shape: " .. ctl.shape, { id = "shape" }) then
ctl.shape = ctl.shape == "radial" and "rect" or "radial"
markDirty()
end
if Kit.button(x + half + gap, cy, half, rowH,
string.format("Reach x%.2f", ctl.rangeMod), { id = "rangemod" }) then
ctl.rangeMod = ctl.rangeMod >= 2 and 0.5 or (ctl.rangeMod + 0.25)
markDirty()
end
cy = cy + rowH + gap
local quarter = (w - gap * 3) / 4
local fields = {
{ "X", round((ctl.x - ctl.rangeX) * canvas.w) },
{ "Y", round((ctl.y - ctl.rangeY) * canvas.h) },
{ "W", round(ctl.rangeX * 2 * canvas.w) },
{ "H", round(ctl.rangeY * 2 * canvas.h) },
}
for i, f in ipairs(fields) do
local fx = x + (quarter + gap) * (i - 1)
Kit.text("small", f[1], fx, cy, PAL.faint)
local id = "num" .. f[1]
local shown = Studio.editing == id and Studio.editBuf or tostring(f[2])
local typed = Kit.textfield(id, fx, cy + Kit.textHeight("small"),
quarter, rowH, shown, "0")
if Kit.focus == id then
Studio.editing, Studio.editBuf = id, typed
elseif Studio.editing == id then
Studio.commitField(id, Studio.editBuf)
Studio.editing, Studio.editBuf = nil, nil
end
end
cy = cy + Kit.textHeight("small") + rowH + gap
Kit.text("small", ("canvas %dx%d px"):format(canvas.w, canvas.h), x, cy, PAL.faint)
cy = cy + Kit.textHeight("small") + gap
local idle = ctl.imagePath or "(none)"
if Kit.button(x, cy, w, rowH, "Idle art: " .. idle, { id = "img" }) then
Studio.imageTarget = "idle"
Studio.cycleImage(1)
end
cy = cy + rowH + gap
local pressed = ctl.pressedImagePath or "(none)"
if Kit.button(x, cy, w, rowH, "Pressed art: " .. pressed, { id = "imgp" }) then
Studio.imageTarget = "pressed"
Studio.cycleImage(1)
end
return cy + rowH
end
local function drawInspector(x, y, w, h)
local pad = 10 * Kit.scale
Kit.card(x, y, w, h)
local scroll = Studio.inspectorScroll or 0
if Kit.hit(x, y, w, h) and (Kit.wheelY or 0) ~= 0 then
scroll = scroll - Kit.wheelY * 48 * Kit.scale
end
local maxScroll = math.max(0, (Studio.inspectorH or 0) - (h - pad * 2))
scroll = math.max(0, math.min(scroll, maxScroll))
Studio.inspectorScroll = scroll
Kit.pushClip(x, y, w, h)
local top = y + pad - scroll
local endY = inspectorBody(x + pad, top, w - pad * 2) or top
Studio.inspectorH = endY - top
Kit.popClip()
if maxScroll > 0 then
local frac = h / (h + maxScroll)
local barH = math.max(24 * Kit.scale, h * frac)
local barY = y + (h - barH) * (scroll / maxScroll)
Theme.fill(x + w - 4 * Kit.scale, barY, 3 * Kit.scale, barH, PAL.line, 0.4)
end
end
function Studio.commitField(id, text)
local ctl = Studio.selectedControl()
if not ctl then return end
local n = tonumber(text)
if not n then return end
local canvas = Studio.canvas()
local left = (ctl.x - ctl.rangeX) * canvas.w
local top = (ctl.y - ctl.rangeY) * canvas.h
local wpx = ctl.rangeX * 2 * canvas.w
local hpx = ctl.rangeY * 2 * canvas.h
if id == "numX" then left = n
elseif id == "numY" then top = n
elseif id == "numW" then wpx = math.max(1, n)
elseif id == "numH" then hpx = math.max(1, n) end
ctl.rangeX = (wpx * 0.5) / canvas.w
ctl.rangeY = (hpx * 0.5) / canvas.h
ctl.x = clamp01((left + wpx * 0.5) / canvas.w)
ctl.y = clamp01((top + hpx * 0.5) / canvas.h)
markDirty()
end
function Studio.draw()
local W, H = love.graphics.getDimensions()
Kit.layout(W, H)
local mx, my = love.mouse.getPosition()
Kit.beginFrame(mx, my, Studio.clicked, Studio.wheel)
Studio.clicked, Studio.wheel = false, 0
Theme.fill(0, 0, W, H, PAL.bg, 1)
local pad = 14 * Kit.scale
local barH = math.max(Kit.tapMin(), 34 * Kit.scale) + pad
Kit.textBold("title", "Skin Studio", pad, pad * 0.6, PAL.heading)
local titleW = Kit.textWidth("title", "Skin Studio") + pad * 2
local btnH = math.max(Kit.tapMin(), 32 * Kit.scale)
local bx = titleW
local canvas = Studio.canvas()
if Kit.button(bx, pad * 0.5, 200 * Kit.scale, btnH,
canvas.label, { id = "canvas" }) then
Studio.setCanvas(Studio.canvasIndex + 1)
end
bx = bx + 208 * Kit.scale
if Kit.button(bx, pad * 0.5, 110 * Kit.scale, btnH,
Studio.testing and "Test: ON" or "Test: OFF",
{ id = "test", active = Studio.testing }) then
Studio.testing = not Studio.testing
TouchControls:setPreview(not Studio.testing)
TouchControls:reset()
end
bx = bx + 118 * Kit.scale
if Studio.dirty then
Kit.text("small", "unsaved", bx, pad * 0.5 + btnH * 0.3, PAL.yellow)
end
local closeW = 100 * Kit.scale
if Kit.button(W - pad - closeW, pad * 0.5, closeW, btnH, "Close",
{ id = "close" }) then
if Studio.onClose then Studio.onClose() end
Kit.endFrame()
return
end
local panelW = math.min(360 * Kit.scale, W * 0.34)
local bodyY = barH + pad * 0.5
local bodyH = H - bodyY - pad
drawInspector(pad, bodyY, panelW, bodyH)
local cx = pad * 2 + panelW
local cw = W - cx - pad
local r = drawCanvas(cx, bodyY, cw, bodyH - 40 * Kit.scale)
local footY = bodyY + bodyH - 30 * Kit.scale
local msg = Studio.status
if not msg and Studio.testing then
local held = {}
for btn in pairs(TouchControls.held or {}) do held[#held + 1] = btn end
table.sort(held)
msg = "TEST: click the pad. Held: "
.. (held[1] and table.concat(held, ", ") or "(none)")
end
if not msg then
msg = "Drag to move, corner handles to resize, blue box is the game screen."
end
Kit.text("small", Kit.ellipsize("small", msg, cw), cx, footY, PAL.detail)
Kit.endFrame()
Studio.canvasArea = r
end
-- ---------------------------------------------------------------- input
function Studio.update()
if not Studio.pendingPlay then return end
Studio.pendingPlay = false
local onPlay, version, canvas = Studio.onPlay, Studio.version, Studio.canvas()
if onPlay then onPlay(version, canvas) end
end
function Studio.mousepressed(x, y, button)
if button ~= 1 then return end
Studio.clicked = true
local r = Studio.lastCanvas
if not r then return end
if Studio.testing then
if x >= r.x and x <= r.x + r.w and y >= r.y and y <= r.y + r.h then
TouchControls:touchpressed("studio", x, y)
end
return
end
local slop = HANDLE * 2 * Kit.scale
if x < r.x - slop or x > r.x + r.w + slop
or y < r.y - slop or y > r.y + r.h + slop then
return
end
Studio.beginCanvasDrag(x, y, r)
end
function Studio.mousemoved(x, y)
if Studio.testing then
TouchControls:touchmoved("studio", x, y)
return
end
if Studio.drag and love.mouse.isDown(1) and Studio.lastCanvas then
Studio.updateDrag(x, y, Studio.lastCanvas)
end
end
function Studio.mousereleased(x, y, button)
if button ~= 1 then return end
if Studio.testing then
TouchControls:touchreleased("studio", x, y)
return
end
Studio.drag = nil
end
function Studio.wheelmoved(_, dy)
Studio.wheel = dy
end
function Studio.textinput(text)
Kit.textinput(text)
end
function Studio.keypressed(key)
if Kit.focus then
Kit.keypressed(key)
return
end
if key == "escape" then
if Studio.onClose then Studio.onClose() end
elseif key == "delete" or key == "backspace" then
Studio.deleteControl()
elseif key == "n" then
Studio.addControl()
elseif key == "d" then
Studio.duplicateControl()
elseif key == "t" then
Studio.testing = not Studio.testing
TouchControls:setPreview(not Studio.testing)
TouchControls:reset()
elseif key == "s" then
Studio.save()
elseif key == "tab" then
local page = Studio.page()
if page and #page.controls > 0 then
Studio.selected = ((Studio.selected or 0) % #page.controls) + 1
end
else
Kit.keypressed(key)
end
end
function Studio.available_desktop()
local osName = love.system and love.system.getOS and love.system.getOS()
return osName ~= "Android" and osName ~= "iOS"
end
return Studio
+6
View File
@@ -17,6 +17,12 @@ local Theme = {
textBox = { tx = 0, ty = 12, tw = 20, th = 6, maxCols = 18 },
-- InitYesNoTextBoxParameters / AskName: hlcoord 14, 7 (YES_NO_MENU 4x3)
choiceBox = { tx = 14, ty = 7, tw = 6, th = 5 },
-- YesNoChoicePokeCenter: hlcoord 11, 6 (HEAL_CANCEL_MENU 7x4, blank line
-- before the first item) -- home/yes_no.asm:21, data/yes_no_menu_strings.asm:16
healCancelBox = { tx = 11, ty = 6, tw = 9, th = 6, firstItem = 2 },
-- EnemySendOutFirstMon inlines its own TWO_OPTION_MENU at hlcoord 0, 7
-- instead of the shared right-hand one -- engine/battle/core.asm:1378-1384
trainerSwitchBox = { tx = 0, ty = 7, tw = 6, th = 5 },
}
function Theme.load(data)
+129 -11
View File
@@ -18,6 +18,7 @@
local SaveData = require("src.core.SaveData")
local TouchControls = require("src.core.TouchControls")
local TouchSkin = require("src.core.TouchSkin")
local PadCursor = require("src.ui.PadCursor")
local GamepadMap = require("src.core.GamepadMap")
@@ -46,6 +47,15 @@ local function roundRect(mode, x, y, w, h, r)
love.graphics.rectangle(mode, x, y, w, h, r, r)
end
local function fitText(font, text, maxW)
text = tostring(text)
if maxW <= 0 or font:getWidth(text) <= maxW then return text end
while #text > 1 and font:getWidth(text .. "...") > maxW do
text = text:sub(1, #text - 1)
end
return text .. "..."
end
function Editor.load(opts)
opts = opts or {}
Editor.onClose = opts.onClose
@@ -53,6 +63,7 @@ function Editor.load(opts)
Editor.hostPoll = opts.hostPoll == true
Editor.drag = nil
Editor.rects = {}
Editor._closed = false
Editor._hostMouse = false
Editor._hostTouches = nil
Editor.fonts = {
@@ -74,9 +85,52 @@ function Editor.load(opts)
TouchControls:applyOptions(applied)
TouchControls:setPreview(true)
Editor.enabled = TouchControls.enabled ~= false
Editor.skins = TouchSkin.list()
Editor.exportMsg = nil
PadCursor.reset()
end
function Editor.skinIndex()
for i, entry in ipairs(Editor.skins or {}) do
if entry.id == TouchControls.skinId then return i + 1 end
end
return 1
end
function Editor.skinLabel()
local entry = (Editor.skins or {})[Editor.skinIndex() - 1]
if not entry then return "Built-in pad" end
local skin = TouchSkin.active
local pages = skin and #skin.pages or 1
if pages > 1 then return entry.id .. " (" .. pages .. " pages)" end
return entry.id
end
function Editor.cycleSkin(dir)
local n = #(Editor.skins or {}) + 1
local idx = ((Editor.skinIndex() - 1 + (dir or 1)) % n) + 1
local entry = Editor.skins[idx - 1]
Editor.exportMsg = nil
TouchControls:selectSkin(entry and entry.id or nil)
TouchControls:ensureImages()
end
function Editor.exportSkin()
local skin = TouchSkin.active
if not skin then return end
local path, missing = TouchSkin.export(skin)
if not path then
Editor.exportMsg = "Export failed: " .. tostring(missing)
return
end
Editor.exportMsg = "Exported to " .. path .. " in your save folder."
if type(missing) == "table" and missing[1] then
Editor.exportMsg = Editor.exportMsg
.. " " .. #missing .. " image(s) were missing and left out."
end
Editor.skins = TouchSkin.list()
end
function Editor.unload()
TouchControls:setPreview(false)
TouchControls:reset()
@@ -84,6 +138,7 @@ function Editor.unload()
Editor.drag = nil
Editor.onClose = nil
Editor.version = nil
Editor.rects = {}
Editor._hostMouse = false
Editor._hostTouches = nil
end
@@ -93,6 +148,7 @@ local function persist()
local cfg = TouchControls:config()
local block = {
enabled = cfg.enabled,
skin = cfg.skin,
layouts = cfg.layouts,
}
if Editor.version == "gold" then
@@ -105,6 +161,8 @@ local function persist()
end
local function close()
if Editor._closed then return end
Editor._closed = true
persist()
local cb = Editor.onClose
Editor.unload()
@@ -158,6 +216,8 @@ function Editor.draw()
col(PAL.bgTop, 0.85)
love.graphics.circle("fill", ox + ww * 0.5, oy + wh * 0.15, math.max(ww, wh) * 0.55)
if TouchSkin.active then TouchControls:draw() end
local pad = 18 * s
local barH = 56 * s
local btnH = 40 * s
@@ -170,16 +230,18 @@ function Editor.draw()
love.graphics.setLineWidth(1)
love.graphics.line(0, oy + barH + pad, fullW, oy + barH + pad)
love.graphics.setFont(Editor.fonts.title)
col(PAL.white)
love.graphics.print("Touch Controls", ox + pad, oy + pad + 4 * s)
-- Done / Reset
local done = { x = ox + ww - pad - btnW, y = oy + pad + (barH - btnH) / 2,
w = btnW, h = btnH }
local reset = { x = done.x - 10 * s - btnW, y = done.y, w = btnW, h = btnH }
Editor.rects.done, Editor.rects.reset = done, reset
love.graphics.setFont(Editor.fonts.title)
col(PAL.white)
love.graphics.print(
fitText(Editor.fonts.title, "Touch Controls", reset.x - 10 * s - (ox + pad)),
ox + pad, oy + pad + 4 * s)
local function chromeBtn(r, label, fill)
col(fill, 0.9)
roundRect("fill", r.x, r.y, r.w, r.h, 8 * s)
@@ -223,9 +285,48 @@ function Editor.draw()
chromeBtn(toggle, on and "Disable" or "Enable",
on and PAL.red or PAL.green)
local skinY = cardY + cardH + 10 * s
col(PAL.card, 0.88)
roundRect("fill", cardX, skinY, cardW, cardH, 12 * s)
col(PAL.stroke, 0.4)
roundRect("line", cardX, skinY, cardW, cardH, 12 * s)
local stepW = 52 * s
local skinNext = { x = cardX + cardW - 16 * s - stepW,
y = skinY + (cardH - btnH) / 2, w = stepW, h = btnH }
local skinPrev = { x = skinNext.x - 10 * s - stepW, y = skinNext.y,
w = stepW, h = btnH }
Editor.rects.skinNext, Editor.rects.skinPrev = skinNext, skinPrev
local leftmost = skinPrev.x
if TouchSkin.active then
local expW = 86 * s
local export = { x = skinPrev.x - 10 * s - expW, y = skinPrev.y,
w = expW, h = btnH }
Editor.rects.export = export
leftmost = export.x
else
Editor.rects.export = nil
end
local skinTextW = leftmost - 10 * s - (cardX + 16 * s)
love.graphics.setFont(Editor.fonts.body)
col(PAL.label)
love.graphics.print("Skin", cardX + 16 * s, skinY + 12 * s)
love.graphics.setFont(Editor.fonts.btn)
col(TouchControls.skinId and PAL.green or PAL.white)
love.graphics.print(fitText(Editor.fonts.btn, Editor.skinLabel(), skinTextW),
cardX + 16 * s, skinY + 34 * s)
chromeBtn(skinPrev, "<", { 60, 70, 110 })
chromeBtn(skinNext, ">", { 60, 70, 110 })
if Editor.rects.export then
chromeBtn(Editor.rects.export, "Export", { 60, 70, 110 })
end
-- size card (#633): -/+ resize every control in the orientation on
-- screen; the heading names it so it is plain the other one is untouched
local sizeY = cardY + cardH + 10 * s
local sizeY = skinY + cardH + 10 * s
col(PAL.card, 0.88)
roundRect("fill", cardX, sizeY, cardW, cardH, 12 * s)
col(PAL.stroke, 0.4)
@@ -243,7 +344,6 @@ function Editor.draw()
string.format("%d%%", math.floor((bucket.scale or 1) * 100 + 0.5)),
cardX + 16 * s, sizeY + 34 * s)
local stepW = 52 * s
local plus = { x = cardX + cardW - 16 * s - stepW,
y = sizeY + (cardH - btnH) / 2, w = stepW, h = btnH }
local minus = { x = plus.x - 10 * s - stepW, y = plus.y, w = stepW, h = btnH }
@@ -254,13 +354,19 @@ function Editor.draw()
-- hint
love.graphics.setFont(Editor.fonts.body)
col(PAL.label, 0.9)
local hint = on
and "Drag each button to reposition, -/+ to resize. Portrait and landscape are saved separately when you tap Done."
or "Controls are hidden in-game. Enable them to show and edit the layout."
local hint
if Editor.exportMsg then
hint = Editor.exportMsg
elseif not on then
hint = "Controls are hidden in-game. Enable them to show and edit the layout."
elseif TouchSkin.active then
hint = "This skin owns its own art, hitboxes and screen position, so size and dragging are off. Drop a RetroArch overlay folder or .zip into the skins folder of your save directory to add more."
else
hint = "Drag each button to reposition, -/+ to resize. Portrait and landscape are saved separately when you tap Done."
end
love.graphics.printf(hint, ox + pad, sizeY + cardH + 12 * s, ww - 2 * pad, "left")
-- the overlay itself (preview mode; dimmed when disabled)
TouchControls:draw()
if not TouchSkin.active then TouchControls:draw() end
-- highlight the control under drag
if Editor.drag then
@@ -282,6 +388,9 @@ local function beginDrag(id, x, y)
if inside(Editor.rects.done, x, y) then close(); return end
if inside(Editor.rects.reset, x, y) then resetLayout(); return end
if inside(Editor.rects.toggle, x, y) then toggleEnabled(); return end
if inside(Editor.rects.skinPrev, x, y) then Editor.cycleSkin(-1); return end
if inside(Editor.rects.skinNext, x, y) then Editor.cycleSkin(1); return end
if inside(Editor.rects.export, x, y) then Editor.exportSkin(); return end
if inside(Editor.rects.sizeDown, x, y) then
TouchControls:nudgeScale(-TouchControls.SCALE_STEP); return
end
@@ -289,6 +398,7 @@ local function beginDrag(id, x, y)
TouchControls:nudgeScale(TouchControls.SCALE_STEP); return
end
if TouchSkin.active then return end
local name = TouchControls:hitTest(x, y)
if not name then return end
local zone = TouchControls:layout()[name]
@@ -321,6 +431,7 @@ function Editor.pollHostPointers()
if not Editor._hostMouse then
Editor._hostMouse = true
beginDrag("mouse", x, y)
if Editor._closed then return end
else
moveDrag("mouse", x, y)
end
@@ -340,6 +451,7 @@ function Editor.pollHostPointers()
if not Editor._hostTouches[id] then
Editor._hostTouches[id] = true
beginDrag(id, tx, ty)
if Editor._closed then return end
else
moveDrag(id, tx, ty)
end
@@ -439,6 +551,12 @@ function Editor.keypressed(key)
close()
elseif key == "r" then
resetLayout()
elseif key == "[" then
Editor.cycleSkin(-1)
elseif key == "]" then
Editor.cycleSkin(1)
elseif key == "e" then
Editor.exportSkin()
-- desktop shortcuts for the size -/+ (POKEPORT_TOUCH=1 testing, #633)
elseif key == "-" or key == "kp-" then
TouchControls:nudgeScale(-TouchControls.SCALE_STEP)
+27 -1
View File
@@ -14,7 +14,9 @@
-- This is what the party-menu FLY field move opens (#195).
local Font = require("src.render.Font")
local PaletteFX = require("src.render.PaletteFX")
local Sound = require("src.core.Sound")
local SpriteRenderer = require("src.render.SpriteRenderer")
local TownMap = {}
TownMap.__index = TownMap
@@ -227,7 +229,21 @@ function TownMap.new(game, opts)
local sprites = game.data.sprites or {}
local red = sprites[playerSprites.walk or "SPRITE_RED"]
or sprites.SPRITE_RED
local ok, img = pcall(love.graphics.newImage, red and red.image)
-- the marker is the overworld walking sheet, so it wears that sheet's OBJ
-- palette and shade-0 keying -- engine/items/town_map.asm:342
local colors, group
if PaletteFX.usesGbcPack() then
colors, group = PaletteFX.spriteObp(red, "player")
end
if not colors then
if PaletteFX.usesSpriteObp() then
colors, group = PaletteFX.ogObj()
else
colors, group = PaletteFX.dmgObj()
end
end
local ok, img = pcall(SpriteRenderer.obpImage,
red and red.image, colors, group)
if ok and img then
self.playerSheet = img
self.playerQuad = love.graphics.newQuad(0, 0, 16, 16,
@@ -316,6 +332,13 @@ function TownMap:update(dt)
end
end
-- OG RED bakes the boot-ROM OBJ palette in, so the marker has to be replayed
-- over the screen-wide TOWNMAP zone pass the way every other OBJ is (#301)
function TownMap:markPlayerRedraw(x, y)
if not PaletteFX.usesSpriteObp() then return end
PaletteFX.markUiSpriteRedraw(self.playerSheet, self.playerQuad, x, y)
end
local function drawSquare(loc)
if isRoute(loc) then
love.graphics.setColor(0.62, 0.62, 0.62, 1) -- routes lighter
@@ -364,6 +387,7 @@ function TownMap:draw()
local x, y = markerXY(self.playerLoc)
if self.playerSheet then
love.graphics.draw(self.playerSheet, self.playerQuad, x - 4, y - 3)
self:markPlayerRedraw(x - 4, y - 3)
else
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", x + 2, y + 2, 4, 4)
@@ -406,6 +430,8 @@ function TownMap:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(self.playerSheet, self.playerQuad,
self.playerLoc.x * 8 - 4, self.playerLoc.y * 8 - 3)
self:markPlayerRedraw(self.playerLoc.x * 8 - 4,
self.playerLoc.y * 8 - 3)
else
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", self.playerLoc.x * 8 + 2,
+3 -6
View File
@@ -256,10 +256,7 @@ function YellowIntro.new(game, onDone)
local ok, canvas = pcall(love.graphics.newCanvas, 256, 256)
self.bgCanvas = ok and canvas or nil
-- Yellow boots exactly like Red up to the attract movie: the copyright
-- card and the GAME FREAK shooting-star splash play first. Reuse
-- IntroMovie's phases 1-2 and take over where its Gengar fight (phase
-- 3) would begin; a skip press during the pre-roll skips everything.
-- intro.asm:311 copyright card, splash.asm:29 shooting star
local IntroMovie = require("src.ui.IntroMovie")
local pre = IntroMovie.new(game, nil)
local baseStart = pre.startPhase
@@ -644,8 +641,8 @@ end
-- Both scene-loop exits (scene 17 running out, and the A/B/START skip) land
-- on .go_to_title_screen, which calls YellowIntro_BlankPalettes and then
-- spends EXIT_FRAMES DelayFrame calls clearing the tilemap and the OAM
-- buffers before the title screen is built (#523). The pre-roll's own skip
-- is IntroMovie's path, not this one, and still finishes immediately.
-- buffers before the title screen is built (#523). A press during the
-- pre-roll splash starts the scenes instead of reaching here.
function YellowIntro:exitToTitle()
if self.finished or self.exiting then return end
self.exiting = true
+74 -18
View File
@@ -48,6 +48,18 @@ local TILE_PLAYER_BOTTOM_LEFT = 0x6f
-- (engine/battle/trainer_huds.asm:143-152).
local TILE_CAUGHT = 0x5d
-- The ball icons StageBallTilesData stages, one per party slot
-- (engine/battle/trainer_huds.asm:47-99).
local TILE_BALL_NORMAL = 0x31
local TILE_BALL_STATUSED = 0x32
local TILE_BALL_FAINTED = 0x33
local TILE_BALL_EMPTY = 0x34
-- DrawPlayerPartyIconHUDBorder's corner (trainer_huds.asm:118-132), which is
-- DrawPlayerHUDBorder's with $77 swapped for $5c.
local TILE_PARTY_ICON_BOTTOM_RIGHT = 0x5c
BattleHud.PARTY_LENGTH = 6
function BattleHud.new(menuGfx, palettes)
local self = setmetatable({}, BattleHud)
self.gfx = menuGfx and menuGfx.battleHud or nil
@@ -184,18 +196,25 @@ local TILE_EXP_FULL = 0x6a -- FontBattleExtra
local TILE_EXP_EMPTY = 0x62 -- FontBattleExtra
local EXP_PARTIAL_BASE = 0x54 -- $54 + remainder lands in ExpBarGFX
-- PAL_BATTLE_BG_EXP, which the attrmap lays over (10,11)..(18,11)
-- (engine/gfx/cgb_layouts.asm:142-145).
function BattleHud:expColors()
local pal = self.palettes and self.palettes.expBar
if not pal then return nil end
return {
{ 255, 255, 255 },
{ pal[1][1], pal[1][2], pal[1][3] },
{ pal[2][1], pal[2][2], pal[2][3] },
{ 0, 0, 0 },
}
end
function BattleHud:drawExpBar(fraction, tx, ty)
if not self:image("hpBar") then return false end
fraction = math.max(0, math.min(1, fraction or 0))
local pixels = math.floor(fraction * BattleHud.EXP_LENGTH_PX)
-- The whole row wears the exp bar's palette, full and empty cells included.
local pal = self.palettes and self.palettes.expBar
local colors = pal and {
{ 255, 255, 255 },
{ pal[1][1], pal[1][2], pal[1][3] },
{ pal[2][1], pal[2][2], pal[2][3] },
{ 0, 0, 0 },
} or nil
local colors = self:expColors()
local remaining = pixels
for cell = BattleHud.EXP_CELLS - 1, 0, -1 do
@@ -269,19 +288,56 @@ end
-- DrawPlayerHUDBorder: hlcoord 18, 10 stepping LEFT, tiles $73 / $77 / $6f /
-- $76, plus the extra vertical bar DrawPlayerHUD writes at (18,9) so the stub
-- is two rows tall.
local PLAYER_FRAME_TILES = {
sideSheet = "playerBorder", sideFirst = PLAYER_BORDER_FIRST,
cornerSheet = "playerBorder", cornerFirst = PLAYER_BORDER_FIRST,
-- $6f is the LAST tile of EnemyHPBarBorderGFX, not the player sheet
-- (engine/gfx/load_font.asm:57-65).
farSheet = "enemyBorder", farFirst = ENEMY_BORDER_FIRST,
side = TILE_PLAYER_RIGHT,
nearCorner = TILE_PLAYER_BOTTOM_RIGHT,
farCorner = TILE_PLAYER_BOTTOM_LEFT,
bottom = TILE_BOTTOM_SIDE,
}
function BattleHud:drawPlayerFrame()
self:drawTile("playerBorder", PLAYER_BORDER_FIRST, TILE_PLAYER_RIGHT, 18, 9)
self:placeBorder({
sideSheet = "playerBorder", sideFirst = PLAYER_BORDER_FIRST,
cornerSheet = "playerBorder", cornerFirst = PLAYER_BORDER_FIRST,
-- $6f is the LAST tile of EnemyHPBarBorderGFX, not the player sheet
-- (engine/gfx/load_font.asm:57-65).
farSheet = "enemyBorder", farFirst = ENEMY_BORDER_FIRST,
side = TILE_PLAYER_RIGHT,
nearCorner = TILE_PLAYER_BOTTOM_RIGHT,
farCorner = TILE_PLAYER_BOTTOM_LEFT,
bottom = TILE_BOTTOM_SIDE,
}, 18, 10, -1)
self:placeBorder(PLAYER_FRAME_TILES, 18, 10, -1)
end
-- DrawPlayerPartyIconHUDBorder (engine/battle/trainer_huds.asm:118-132): the
-- player border with $5c for the bottom right, and no bar at (18,9).
function BattleHud:drawPartyIconFrame()
self:placeBorder(PLAYER_FRAME_TILES, 18, 10, -1)
return self:drawTile("expBar", self.gfx and self.gfx.expBarFirstTile,
TILE_PARTY_ICON_BOTTOM_RIGHT, 18, 11, self:expColors())
end
-- StageBallTilesData's .GetHUDTile, and the $34 it stages past the party
-- count (engine/battle/trainer_huds.asm:47-100).
local function ballTile(mon)
if not mon then return TILE_BALL_EMPTY end
if (mon.hp or 0) <= 0 then return TILE_BALL_FAINTED end
return mon.status and TILE_BALL_STATUSED or TILE_BALL_NORMAL
end
-- PAL_BATTLE_OB_YELLOW (engine/battle/trainer_huds.asm:213-214).
function BattleHud:ballColors()
local pals = self.palettes and self.palettes.battleObjects
return pals and pals.PAL_BATTLE_OB_YELLOW or nil
end
-- LoadTrainerHudOAM (engine/battle/trainer_huds.asm:203-223): six sprites
-- from (tx, ty), each one tile further along `step`.
function BattleHud:drawBallRow(party, tx, ty, step)
if not self:image("balls") then return false end
local first = self.gfx.ballsFirstTile or TILE_BALL_NORMAL
local colors = self:ballColors()
for slot = 1, BattleHud.PARTY_LENGTH do
self:drawTile("balls", first, ballTile(party and party[slot]),
tx + (slot - 1) * step, ty, colors)
end
return true
end
BattleHud.TILE_HP_LABEL = TILE_HP_LABEL
+106 -10
View File
@@ -314,6 +314,13 @@ function BattleState.new(game, opts)
self.showEnemyHud = false
self.showPlayerHud = false
-- BattleStart_TrainerHuds, farcalled from BattleStartMessage before the
-- opening line (engine/battle/trainer_huds.asm:1-9, core.asm:8733).
self.ballRows = {
player = true,
enemy = not (self.battle and self.battle.wild),
}
-- InitEnemyTrainer (engine/battle/core.asm:7848) puts the CLASS's 7x7
-- frontpic in the enemy pic box BEFORE the intro slide, and it stays there
-- until ResetEnemyBattleVars slides it off; only then is the mon drawn. The
@@ -349,6 +356,7 @@ function BattleState.new(game, opts)
local enemy = self.battle and self.battle.enemy
self:noteFirstUnown(enemy)
self:markSeen(enemy)
if enemy then
if self.battle.wild then
-- BattleCheckEnemyShininess: a shiny wild mon gets ANIM_SEND_OUT_MON's
@@ -399,6 +407,13 @@ function BattleState.new(game, opts)
-- "X fainted!" is even displayed. The replacement arrives with its own
-- `send` event, which is where the cart's send-out animation sits.
self.shownMon = { player = player, enemy = enemy }
-- home/battle.asm:150 UpdateBattleHuds
self.shownStatus = {
player = (player and player.status) or false,
enemy = (enemy and enemy.status) or false,
}
-- engine/battle/trainer_huds.asm:142-151
self.caughtMark = self:dexCaught(enemy)
-- And the same for the two numbers AnimateExpBar walks: wBattleMonLevel is
-- only advanced inside its level loop, right after that level's bar has
-- crawled full (engine/battle/core.asm:7267-7274), so neither the level nor
@@ -443,6 +458,16 @@ function BattleState:noteFirstUnown(mon)
save.firstUnownSeen = Unown.monLetter(mon)
end
-- LoadEnemyMon's "Saw this mon" (engine/battle/core.asm:6203-6209): the seen
-- flag is stamped for every battle mode, so a trainer's mon counts too.
function BattleState:markSeen(mon)
local save = self.save
if not (save and mon and mon.species) then return end
save.pokedex = save.pokedex or { seen = {}, caught = {} }
save.pokedex.seen = save.pokedex.seen or {}
save.pokedex.seen[mon.species] = true
end
-- The DUDE answering a prompt. Every re-arm in the ASM sits at the moment the
-- cart starts WAITING for a button (`.wait_input` in home/joypad.asm, BattleMenu
-- before LoadBattleMenu, TutorialPack before its own loop), so each one goes
@@ -936,6 +961,25 @@ function BattleState:dexCaught(mon)
return (mon and caught and caught[mon.species]) and true or false
end
-- The status the HUD prints, one drain behind the engine the way shownHp is:
-- UpdateBattleHuds runs after the animation and its line (home/battle.asm:150).
function BattleState:hudStatus(mon, side)
local shown = side and self.shownStatus and self.shownStatus[side]
if shown == nil then return mon and mon.status or nil end
return shown or nil
end
-- home/battle.asm:150, for the clears no queued event carries (a mon waking,
-- a thaw, a bag cure): the tags catch up once the queue is idle.
function BattleState:syncShownStatus()
local shown, battle = self.shownStatus, self.battle
if not (shown and battle) then return end
for _, side in ipairs({ "player", "enemy" }) do
local mon = battle[side]
shown[side] = (mon and mon.status) or false
end
end
-- The low-HP alarm is not an SFX id at all. PlayDanger (audio/engine.asm:531)
-- runs every frame while DANGER_ON_F is set in wLowHealthAlarm and writes a
-- two-tone square straight to channel 1 -- DangerSoundHigh ($750) at counter 0,
@@ -1153,7 +1197,7 @@ function BattleState:stepAnim(input)
-- Cart still reaches the after-anim arm after a move script ends; a skip
-- of the move should not drop the hit shake that follows it.
if self:startPendingAfterAnim() then return end
return self:endSendOutAnim()
return self:endSendOutAnim(true)
end
if not self.anim:step() then
self:latchCaughtPic()
@@ -1170,10 +1214,17 @@ end
-- Whatever Call_PlayBattleAnim was standing in front of: a send-out's cry and
-- HUD update run the moment its animation is done, cut short or not.
function BattleState:endSendOutAnim()
function BattleState:endSendOutAnim(skipped)
local after = self.afterSendOut
if not after then return end
self.afterSendOut = nil
if after.shiny and not skipped then
after.shiny = nil
if self:animForId("ANIM_SEND_OUT_MON", after.side, 1) then
self.afterSendOut = after
return
end
end
self:finishSendOut(after)
end
@@ -1202,6 +1253,7 @@ function BattleState:advanceQueue()
self.introTextShown = nil
end
if not event then
self:syncShownStatus()
-- `jp PlayerSwitch`, which follows the enemy's own send-out and spends no
-- turn (engine/battle/core.asm:2955-2963).
if self.shiftSwitchIndex then
@@ -1315,6 +1367,14 @@ function BattleState:advanceQueue()
self.shownHp[event.side] = event.mon.hp or 0
if self.hpAnim and self.hpAnim.side == event.side then self.hpAnim = nil end
end
-- And the same lag for the status tag (home/battle.asm:150); a send snaps it
-- to the incoming mon.
if self.shownStatus and event.side
and (event.kind == "status"
or (event.kind == "send" and event.mon)) then
self.shownStatus[event.side] =
(event.kind == "send" and event.mon.status or event.status) or false
end
-- AnimateExpBar (engine/battle/core.asm:7191) is called from INSIDE
-- GiveExperiencePoints before the exp is committed (the call at :6888 sits
-- ahead of the commit at :6889-6901), so the bar crawls from the figures
@@ -1339,7 +1399,12 @@ function BattleState:advanceQueue()
-- is still on screen for its own line and the replacement arrives here.
if self.shownMon then self.shownMon[event.side] = event.mon end
-- The second of the cart's two wFirstUnownSeen writes (core.asm:3251).
if event.side == "enemy" then self:noteFirstUnown(event.mon) end
if event.side == "enemy" then
self:noteFirstUnown(event.mon)
self:markSeen(event.mon)
-- engine/battle/trainer_huds.asm:142-151
self.caughtMark = self:dexCaught(event.mon)
end
if event.side == "player" then
-- SendOutPlayerMon zeroes wBattleMenuCursorPosition and wCurMoveNum back
-- to back (engine/battle/core.asm:3809), so a switched-in mon opens on
@@ -1535,12 +1600,18 @@ end
-- SetEnemyTurn / SetPlayerTurn, then ANIM_SEND_OUT_MON. The cry and the HUD
-- come after the animation, not with it.
function BattleState:startSendOut(side, mon)
local after = { side = side, mon = mon }
-- BattleCheckPlayerShininess / BattleCheckEnemyShininess replay the anim's
-- `.Shiny` arm before the cry (core.asm:3826-3831, :3371-3377).
local after = { side = side, mon = mon, shiny = mon and mon.shiny and true }
-- ShowSetEnemyMonAndSendOutAnimation and SendOutPlayerMon both draw the pic
-- into the box before they play the animation, which is the one thing that
-- undoes a cleared box.
self.picHidden[side] = false
self.faintSlide = nil
-- The rows are shadow OAM (engine/battle/trainer_huds.asm:203-223), which
-- this animation's own sprites overwrite.
self.ballRows.player = false
self.ballRows.enemy = false
if self:animForId("ANIM_SEND_OUT_MON", side) then
self.afterSendOut = after
return true
@@ -2569,6 +2640,9 @@ function BattleState:pushCaught(enemy, itemId)
-- in the box", item_effects.asm:624). Boxes.deposit stays an append: the
-- PC's own move is InsertPokemonIntoBox, which inserts at the cursor.
table.insert(box, 1, enemy)
-- SendMonIntoBox refills the boxed slot's PP before it closes SRAM
-- (move_mon.asm:1062-1063).
Boxes.restorePP(enemy)
-- `.SendToPC` re-reads sBoxCount AFTER the insert and sets
-- BATTLERESULT_BOX_FULL when the box has just filled
-- (item_effects.asm:612-619); Script_reloadmapafterbattle tests that bit
@@ -2638,6 +2712,9 @@ end
-- (engine/battle/core.asm:3298-3304, data/text/battle.asm:222-231).
function BattleState:offerShiftSwitch(mon)
self.shiftIndex = 1
-- HandleEnemySwitch farcalls EnemySwitch_TrainerHud before the prompt
-- (engine/battle/core.asm:2246, engine/battle/trainer_huds.asm:11-15).
self.ballRows.enemy = true
local trainer = (self.battle.trainer and self.battle.trainer.name) or "Foe"
local player = (self.save and self.save.player and self.save.player.name)
or "GOLD"
@@ -2921,6 +2998,9 @@ function BattleState:useItem(itemId)
end
local player = self.battle.player
caught, rate = Catching.attempt({
battle = self.battle,
mon = enemy,
def = enemyDef,
maxHp = enemy.maxHp or (enemy.stats and enemy.stats.hp),
hp = enemy.hp,
catchRate = enemyDef and enemyDef.catchRate or 45,
@@ -3231,13 +3311,13 @@ function BattleState:drawHud()
-- PlaceNonFaintStatus (engine/pokemon/mon_stats.asm): a statused mon's tag
-- prints where the level goes, and DrawEnemyHUD's `.skip_level` arm drops
-- the level entirely while one is up.
Chrome.print(self:statusTag(enemy) or ("<LV>" .. tostring(enemy.level or 1)),
6, 1)
Chrome.print(self:statusTag(enemy, "enemy")
or ("<LV>" .. tostring(enemy.level or 1)), 6, 1)
local enemyGender = self:genderSymbol(enemy)
if enemyGender then Chrome.print(enemyGender, 9, 1) end
-- `ld a, [wBattleMode] / dec a / ret nz`, then CheckCaughtMon puts $5d at
-- (1,1) (engine/battle/trainer_huds.asm:140-152).
if self.battle and self.battle.wild and self:dexCaught(enemy) then
if self.battle and self.battle.wild and self.caughtMark then
self.hud:drawCaughtIcon(1, 1, self:hudHp(enemy, "enemy"),
enemy.maxHp or (enemy.stats and enemy.stats.hp))
end
@@ -3249,6 +3329,14 @@ function BattleState:drawHud()
self:drawFrame(1, 3, 10, false)
end
end
-- ShowOTTrainerMonsRemaining (engine/battle/trainer_huds.asm:32-45): balls
-- walking LEFT from OAM (72, 32), which the -8/-16 offset puts at (8, 2).
if showStatus and self.ballRows.enemy then
if not self.showEnemyHud and self.hud:available() then
self.hud:drawEnemyFrame()
end
self.hud:drawBallRow(self.battle and self.battle.enemyParty, 8, 2, -1)
end
self:drawPic(enemy, false)
@@ -3257,6 +3345,14 @@ function BattleState:drawHud()
-- the HP bar at (10,9), its numbers below; the vertical bar at (18,9), the
-- border from (18,10) going left, and the exp bar at (10,11).
self:drawPic(player, true)
-- ShowPlayerMonsRemaining (engine/battle/trainer_huds.asm:17-30): balls
-- walking RIGHT from OAM (96, 96), i.e. tile (11, 10).
if showStatus and self.ballRows.player then
if not self.showPlayerHud and self.hud:available() then
self.hud:drawPartyIconFrame()
end
self.hud:drawBallRow(self.battle and self.battle.party, 11, 10, 1)
end
-- No player HUD in the catching tutorial: DrawPlayerHUD lives in
-- SendOutPlayerMon, which BATTLETYPE_TUTORIAL jumps straight over, and
-- BattleMenu's own tutorial arm skips UpdateBattleHuds as well. The DUDE's
@@ -3270,7 +3366,7 @@ function BattleState:drawHud()
Chrome.print(self:name(player), 10, 7)
-- PrintPlayerHUD places the same status tag at (14,8) and skips the level
-- while it is up.
Chrome.print(self:statusTag(player)
Chrome.print(self:statusTag(player, "player")
or ("<LV>" .. tostring(self.shownLevel or player.level or 1)), 14, 8)
local playerGender = self:genderSymbol(player)
if playerGender then Chrome.print(playerGender, 17, 8) end
@@ -3303,8 +3399,8 @@ local STATUS_TAGS = {
paralyze = "PAR", sleep = "SLP",
}
function BattleState:statusTag(mon)
return mon and STATUS_TAGS[mon.status] or nil
function BattleState:statusTag(mon, side)
return mon and STATUS_TAGS[self:hudStatus(mon, side)] or nil
end
-- ♂ / ♀ after the level, or nil for a genderless species (PrintPlayerHUD
+47 -18
View File
@@ -78,8 +78,12 @@ local MOVE_SUBMENU = { "MOVE", "STATS", "CANCEL" }
-- engine/pokemon/bills_pc.asm:472-478: BillsPC_Withdraw's menu rows.
local WITHDRAW_SUBMENU = { "WITHDRAW", "STATS", "RELEASE", "CANCEL" }
-- BillsPCDepositMenuHeader's .MenuData (engine/pokemon/bills_pc.asm:234-240).
local DEPOSIT_SUBMENU = { "DEPOSIT", "STATS", "RELEASE", "CANCEL" }
function BoxMenu:submenuRows()
if self.mode == "move" then return MOVE_SUBMENU end
if self.mode == "deposit" then return DEPOSIT_SUBMENU end
return WITHDRAW_SUBMENU
end
@@ -219,21 +223,12 @@ function BoxMenu:act()
if self.onClose then self.onClose() end
return
end
-- engine/pokemon/bills_pc.asm:336-344: withdraw and move both PrepSubmenu.
if self.mode == "move" or self.mode == "withdraw" then
if not self:selected() then return end
self.phase = "submenu"
-- `ld a, $1 / ld [wMenuCursorY], a`: the submenu always opens on MOVE.
self.submenuIndex = 1
return
end
local ok, result = Boxes.deposit(self.save, self.index, self.boxIndex)
if not ok then
self.message = result
return
end
self.message = nil
self:clampIndex()
-- engine/pokemon/bills_pc.asm:336-344: withdraw and move both PrepSubmenu,
-- and _DepositPKMN's .a_button steps to .WhatsUp the same way (:94-102).
if not self:selected() then return end
self.phase = "submenu"
-- `ld a, $1 / ld [wMenuCursorY], a`: the submenu always opens on its top row.
self.submenuIndex = 1
end
-- ------------------------------------------------------------ MOVE, step 2
@@ -243,8 +238,9 @@ end
-- the PARTY, so a boxed mon walks straight past. Returns true, or false and
-- the string the cart places.
function BoxMenu:checkMailPreventBlackout()
-- `ld a, [wBillsPC_LoadedBox] / and a / jr nz, .Okay`.
if not self:isParty() then return true end
-- `ld a, [wBillsPC_LoadedBox] / and a / jr nz, .Okay`; _DepositPKMN zeroes
-- that byte too, so its list is the party (bills_pc.asm:17-18).
if not (self:isParty() or self.mode == "deposit") then return true end
local party = self.save.party or {}
-- `cp $3 / jr c, .ItsYourLastPokemon`: a party of one or two may not send
-- one away at all, however healthy the rest of it is.
@@ -311,10 +307,25 @@ function BoxMenu:doWithdraw()
self:clampIndex()
end
-- engine/pokemon/bills_pc.asm:155 BillsPCDepositFuncDeposit
function BoxMenu:doDeposit()
local ok, result = Boxes.deposit(self.save, self.index, self.boxIndex)
if not ok then
self.message = result
return
end
self.message = nil
self.phase = nil
self.index, self.scroll = 1, 0
self:clampIndex()
end
function BoxMenu:chooseSubmenu()
local row = self:submenuRows()[self.submenuIndex]
if row == "MOVE" then
self:beginMove()
elseif row == "DEPOSIT" then
self:doDeposit()
elseif row == "WITHDRAW" then
self:doWithdraw()
elseif row == "STATS" then
@@ -393,6 +404,9 @@ function BoxMenu:insertMon()
target = target - 1
end
table.insert(dest, math.max(1, math.min(target, #dest + 1)), mon)
-- .CopyToBox is InsertPokemonIntoBox, which tails into
-- RestorePPOfDepositedPokemon (engine/pokemon/move_mon_wo_mail.asm:35-37).
if not self:isParty(destIndex) then Boxes.restorePP(mon) end
self.phase = nil
self.moveFrom, self.backup = nil, nil
self.index, self.scroll = 1, 0
@@ -521,6 +535,16 @@ function BoxMenu:askRelease()
if self:isCancel() then return end
local mon = self:selected()
if not mon then return end
-- BillsPCDepositFuncRelease runs the mail/blackout net BEFORE the egg check
-- (engine/pokemon/bills_pc.asm:183-187).
if self.mode == "deposit" then
local allowed, refusal = self:checkMailPreventBlackout()
if not allowed then
self.phase = nil
self.message = refusal
return
end
end
-- Both release paths run BillsPC_IsMonAnEgg first, so the question is never
-- even asked over an egg (engine/pokemon/bills_pc.asm:186-187 and :427-428).
if mon.isEgg then
@@ -534,7 +558,12 @@ function BoxMenu:askRelease()
local name = mon.nickname or mon.name or mon.species or "?"
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then return end
local ok, err = Boxes.release(self.save, self.boxIndex, self.index)
local ok, err
if self.mode == "deposit" then
ok, err = Boxes.releaseFromParty(self.save, self.index)
else
ok, err = Boxes.release(self.save, self.boxIndex, self.index)
end
if not ok then
self.message = err
return
+3 -2
View File
@@ -518,8 +518,9 @@ function ItemPcMenu:drawList()
local entry = self.rows[i]
if i == self.listIndex then Chrome.cursor(5, ty) end
Chrome.print(entry.name, 6, ty)
-- PlaceMenuItemQuantity: the xNN is the entry's second line.
Chrome.print(TIMES .. tostring(entry.count), 7, ty + 1)
-- PlaceMenuItemQuantity (engine/menus/menu_2.asm:24): the xNN is the
-- entry's second line, right-aligned in a blank-padded 2-digit field.
Chrome.print(TIMES .. Chrome.number(entry.count, 2), 7, ty + 1)
elseif i == self:listTotal() then
if i == self.listIndex then Chrome.cursor(5, ty) end
Chrome.print("CANCEL", 6, ty)
+10 -10
View File
@@ -12,9 +12,9 @@
-- * The two tilemaps behind DrawMagnetTrain (MagnetTrainBGTiles, a 2x18
-- vertical strip repeated across all 32 columns, and MagnetTrainTilemap,
-- the 20x4 train laid over rows 6-9) come from the extracted cache at
-- data.field.magnetTrain. A cache built before the extractor learned to
-- follow them has neither, and then the ride runs with a blank screen
-- rather than with invented art.
-- data.gen2Field.magnetTrain. A cache built before the extractor
-- learned to follow them has neither, and then the ride runs with a
-- blank screen rather than with invented art.
-- * The background is baked into a 256x144 canvas once, because the only
-- thing that changes per frame is the per-band SCX.
-- * SetMagnetTrainPals gives the four bush rows and the four bottom rows
@@ -57,7 +57,7 @@ function MagnetTrainRide.new(game, opts)
self.onDone = opts.onDone
self.finished = false
local field = self.data and self.data.field
local field = self.data and self.data.gen2Field
local gfx = field and field.magnetTrain
self.ride = MagnetTrain.new({
toGoldenrod = opts.toGoldenrod,
@@ -65,8 +65,8 @@ function MagnetTrainRide.new(game, opts)
fgTilemap = gfx and gfx.tilemap,
})
self.tileset = self.data and self.data.tilesets
and self.data.tilesets.TILESET_TRAIN_STATION
self.tileset = self.data and self.data.gen2Tilesets
and self.data.gen2Tilesets.TILESET_TRAIN_STATION
self.palettes = self:bgPalettes()
self.spriteSheet = self:playerSheet()
@@ -84,7 +84,7 @@ end
-- even though both stations are INDOOR maps.
function MagnetTrainRide:bgPalettes()
local data = self.data
local palettes = data and data.palettes
local palettes = data and data.gen2Palettes
if not palettes then return nil end
return Palettes.bgSet(palettes, { environment = "TOWN" },
Palettes.clockDaytime())
@@ -129,7 +129,7 @@ end
-- are cut per 8x8 sub-tile rather than by the sheet's 16-pixel width, because
-- the OAM data addresses the four tiles of a frame individually.
function MagnetTrainRide:playerSheet()
local sprites = self.data and self.data.sprites
local sprites = self.data and self.data.gen2Sprites
local def = sprites and (sprites.SPRITE_CHRIS or sprites.SPRITE_KRIS)
local path = def and def.image
if not path then return nil end
@@ -259,8 +259,8 @@ end
-- MapObjectPals' PAL_OW_RED, the palette every .OAMData_MagnetTrainRed entry
-- names.
function MagnetTrainRide:playerPalette()
local palettes = self.data and self.data.palettes
local sprites = self.data and self.data.sprites
local palettes = self.data and self.data.gen2Palettes
local sprites = self.data and self.data.gen2Sprites
if not palettes then return nil end
return Palettes.spritePalette(palettes, Palettes.clockDaytime(),
sprites and sprites.SPRITE_CHRIS)
+9
View File
@@ -211,6 +211,15 @@ local ROWS = {
TC.buzz(options.haptics)
if game and game.persistOptions then game:persistOptions() end
end },
{ label = "MAX FPS", key = "fpsCap", port = true,
cycle = function(options, delta)
local FrameCap = require("src.core.FrameCap")
options.fpsCap = FrameCap.cycle(options.fpsCap, delta)
FrameCap.apply(options.fpsCap)
end,
text = function(options)
return require("src.core.FrameCap").label(options.fpsCap)
end },
{ label = "CANCEL", cancel = true },
}
+93 -27
View File
@@ -67,6 +67,10 @@ local SUBMENU_LABEL = {
-- (data/text/common_2.asm), the three lines TossMenu prints in order.
local TOSS_HOW_MANY = { "Throw away how", "many?" }
-- _AskItemMoveText (data/text/common_2.asm:322), printed while wSwitchItem
-- holds a row and the cursor is looking for its new home.
local ASK_ITEM_MOVE = { "Where should this", "be moved to?" }
-- _YouDontHaveAMonText and .AnEggCantHoldAnItemText, GiveItem's two refusals.
local NO_POKEMON = { "You don't have a", "#MON!" }
local EGG_CANT_HOLD = { "An EGG can't hold", "an item." }
@@ -127,6 +131,9 @@ function PackMenu.new(game, opts)
self.game = game
self.save = opts.save or (game and game.save)
self.items = opts.items or (game and game.data and game.data.items)
-- Bag.order / Bag.move read `.items` off an injectable data table, and the
-- one this screen draws from is not always the Data singleton.
self.bagData = { items = self.items }
self.world = opts.world or (game and game.world)
self.onChoose = opts.onChoose
self.onClose = opts.onClose
@@ -207,7 +214,13 @@ end
function PackMenu:rebuild()
local pocket = self:pocket().id
local rows = {}
for itemId, raw in pairs((self.save and self.save.inventory) or {}) do
local save = self.save
local inventory = (save and save.inventory) or {}
-- Row order IS wBagItems' order, which is what SELECT rewrites.
local order = (save and save.inventory)
and Bag.order(save, self.bagData) or {}
for _, itemId in ipairs(order) do
local raw = inventory[itemId]
-- A count that is not a number at all (a hand-written save, a mod, an old
-- migration) counts as one rather than raising out of the draw.
local count = tonumber(raw) or (raw and 1) or 0
@@ -219,18 +232,13 @@ function PackMenu:rebuild()
name = PackMenu.label(itemId, def),
teaches = self:moveLabel(def and def.teaches),
tmNumber = def and def.tmNumber,
-- KEY_ITEM and TM_HM rows do not show a quantity on the cart.
showCount = pocket == "ITEM" or pocket == "BALL",
index = def and def.index or math.huge,
-- A KEY_ITEM never shows one, and engine/items/tmhm.asm:390 skips the
-- count for an HM only -- a TM prints ×NN like any other stack.
showCount = pocket == "ITEM" or pocket == "BALL"
or (pocket == "TM_HM" and tostring(itemId):sub(1, 3) ~= "HM_"),
}
end
end
-- Bag order on the cart is acquisition order; without that recorded, item id
-- order is the stable, reproducible choice.
table.sort(rows, function(a, b)
if a.index ~= b.index then return a.index < b.index end
return a.id < b.id
end)
self.rows = rows
self.index = math.min(self.index, #rows + 1)
if self.index < 1 then self.index = 1 end
@@ -671,6 +679,11 @@ function PackMenu:update(_dt)
self:updateQuantity(input)
return
end
-- engine/items/pack.asm:1237 Pack_InterpretJoypad .switching_item
if self.switching then
self:updateSwitch(input)
return
end
if self.message then
if input:wasPressed("a") or input:wasPressed("b") then
self.message = nil
@@ -716,11 +729,52 @@ function PackMenu:update(_dt)
end
return
elseif input:wasPressed("select") then
self:registerSelected()
self:armSwitch()
return
end
end
-- engine/items/pack.asm:1290 Pack_InterpretJoypad .select
function PackMenu:armSwitch()
if self:isCancel() then return end
if not self.rows[self.index] then return end
self.switching = self.index
self.message = ASK_ITEM_MOVE
end
-- `.switching_item` (engine/items/pack.asm:1297): A or SELECT places, B backs
-- out, and left/right cannot leave the pocket mid-move.
function PackMenu:updateSwitch(input)
if input:wasPressed("up") then
self.index = self.index > 1 and self.index - 1 or self:total()
self:ensureVisible()
elseif input:wasPressed("down") then
self.index = self.index < self:total() and self.index + 1 or 1
self:ensureVisible()
elseif input:wasPressed("a") or input:wasPressed("select") then
self:placeSwitch()
elseif input:wasPressed("b") then
self:endSwitch()
end
end
-- engine/items/pack.asm:1307 .place_insert / .end_switch
function PackMenu:placeSwitch()
local from = self.switching
local row = self.rows[from]
if row and not self:isCancel() and self.index ~= from then
Bag.move(self.save, row.id, self:pocket().id, self.index, self.bagData)
self:rebuild()
self:storeCursor()
end
self:endSwitch()
end
function PackMenu:endSwitch()
self.switching = nil
self.message = nil
end
-- VerticalMenu over the submenu rows: up/down wrap, A picks, B is the carry
-- that ExitMenu answers with (`ret c`), which is QUIT by another name.
function PackMenu:updateSubmenu(input)
@@ -777,12 +831,10 @@ function PackMenu:updateConfirm(input)
end
end
-- RegisterItem (engine/items/pack.asm), the submenu's SEL row. SELECT on the
-- highlighted row reaches the same routine: the cart's SELECT is the bag's own
-- item shuffle, which this port does not have, so the button is free and a
-- player who knows Gen 1's registration shortcut gets it. World:registerItem
-- re-runs CheckSelectableItem's gate (TM/HM and anything CANT_SELECT_F
-- refuses), so neither door can register what the cart would not.
-- RegisterItem (engine/items/pack.asm), the submenu's SEL row and its ONLY
-- door -- the cart's SELECT is the bag's own item shuffle (see armSwitch).
-- World:registerItem re-runs CheckSelectableItem's gate (TM/HM and anything
-- CANT_SELECT_F refuses), so it cannot register what the cart would not.
function PackMenu:registerSelected()
if self:isCancel() then return end
local row = self.rows[self.index]
@@ -828,22 +880,38 @@ end
-- The list, description and cursor, on top of whatever chrome was drawn.
--
-- PlaceMenuItemQuantity (engine/menus/menu_2.asm) writes the ×N one row DOWN
-- and one column RIGHT of the name -- the quantity is the entry's second line,
-- not a right-aligned column, which is why every PACK row is two tiles tall.
-- PlaceMenuItemQuantity (engine/menus/menu_2.asm:10) writes the ×N one row
-- DOWN and one column RIGHT of the name -- the quantity is the entry's second
-- line, not a right-aligned column, which is why every PACK row is two tiles
-- tall. Its `lb bc, 1, 2` is a TWO-digit field with the leading digit blanked,
-- so the ones digit sits at name + 3 whether the count is 5 or 50.
--
-- ScrollingMenu_PlaceCursor (engine/menus/scrolling_menu.asm:438) marks the
-- row SELECT armed with the hollow ▷ while the solid ▶ goes on looking.
function PackMenu:drawList(listX, listY)
for row = 1, VISIBLE_ROWS do
local i = row + self.scroll
local ty = listY + (row - 1) * LIST_SPACING
if i <= #self.rows then
local entry = self.rows[i]
if i == self.index then Chrome.cursor(listX - 1, ty) end
if i == self.index then
Chrome.cursor(listX - 1, ty)
elseif i == self.switching then
Chrome.cursor(listX - 1, ty, true)
end
Chrome.print(entry.name, listX, ty)
if entry.teaches then
-- The TM pocket puts the move the TM teaches on that second line.
-- The TM pocket puts the move the TM teaches on that second line, and
-- its count at listX + 9 (engine/items/tmhm.asm:392) -- on the LABEL's
-- line here, since the move name owns the one below it.
Chrome.print(entry.teaches, listX + 1, ty + 1)
if entry.showCount then
Chrome.print("\xc3\x97" .. Chrome.number(entry.count, 2),
listX + 9, ty)
end
elseif entry.showCount then
Chrome.print("\xc3\x97" .. tostring(entry.count), listX + 1, ty + 1)
Chrome.print("\xc3\x97" .. Chrome.number(entry.count, 2),
listX + 1, ty + 1)
end
elseif i == self:total() then
if i == self.index then Chrome.cursor(listX - 1, ty) end
@@ -897,12 +965,10 @@ function PackMenu:drawSubmenu()
end
end
-- TossItem_MenuHeader is `menu_coords 15, 9, SCREEN_WIDTH - 1, TEXTBOX_Y - 1`
-- with NoPriceToDisplay behind it: a small box in the bottom right holding
-- nothing but the count.
-- engine/items/buy_sell_toss.asm:133 BuySellToss_UpdateQuantityDisplay
function PackMenu:drawQuantity()
Chrome.box(15, 9, 5, 3)
Chrome.print("\xc3\x97" .. tostring(self.qtyState.qty), 16, 10)
Chrome.print("\xc3\x97" .. Chrome.number(self.qtyState.qty, 2, true), 16, 10)
end
-- YesNoBox's own coords, the same box every other Gen 2 screen here draws.
+3 -7
View File
@@ -20,6 +20,7 @@ local Chrome = require("src.ui.gen2.Chrome")
local Font = require("src.render.Font")
local GbcPalette = require("src.render.GbcPalette")
local HpBar = require("src.battle.gen2.HpBar")
local ItemEffects = require("src.core.gen2.ItemEffects")
local Logger = require("src.core.Logger")
local Mail = require("src.core.gen2.Mail")
local Mon = require("src.battle.gen2.Mon")
@@ -668,17 +669,12 @@ end
-- PlaceStatusString (engine/pokemon/mon_stats.asm): three letters, and a mon
-- with no HP reads FNT whatever its status byte says.
local STATUS_STRING = {
slp = "SLP", psn = "PSN", brn = "BRN", frz = "FRZ", par = "PAR",
poison = "PSN", burn = "BRN", freeze = "FRZ", paralysis = "PAR",
sleep = "SLP", toxic = "PSN",
}
local function statusString(mon)
if (mon.hp or 0) <= 0 then return "FNT" end
local status = mon.status
if not status then return nil end
return STATUS_STRING[tostring(status):lower()]
local class = ItemEffects.STATUS_CLASS[tostring(status):lower()]
return class and class:upper()
end
-- One list row's strings, exactly what WritePartyMenuTilemap's quality
+127 -2
View File
@@ -24,6 +24,8 @@ local Chrome = require("src.ui.gen2.Chrome")
local Logger = require("src.core.Logger")
local Mail = require("src.core.gen2.Mail")
local Runtime = require("src.mods.Runtime")
local Save = require("src.core.gen2.Save")
local SaveMenu = require("src.ui.gen2.SaveMenu")
local Screens = require("src.ui.Screens")
local Strings = require("src.core.Strings")
@@ -36,6 +38,13 @@ local MON_HOLDING_MAIL = {
Strings.source("Please remove the\nMAIL."),
}
-- _ChangeBoxSaveText (data/text/common_2.asm:1306) is three lines whose `cont`
-- has already scrolled by the time YesNoBox goes up over its last two.
local CHANGE_BOX_SAVE = { "#MON BOX, data", "will be saved. OK?" }
-- YesNoBox's own `lb bc, SCREEN_WIDTH - 6, 7` (home/menu.asm:382-383).
local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 14, 7, 6, 5
local PcMenu = {}
PcMenu.__index = PcMenu
PcMenu.isOpaque = true
@@ -90,6 +99,13 @@ function PcMenu.new(game, opts)
self.onClose = opts.onClose
self.house = opts.house and true or false
self.events = opts.events
-- The same route the start menu's SAVE row takes (src/core/Game2.lua:435),
-- so the save.write veto and the save.writing event fire here too.
self.writer = opts.writer
or (game and type(game.writeSave) == "function"
and function() return game:writeSave() end)
or Save.save
self.saveExists = opts.saveExists
-- The folded MAIL BOX row belongs to the item PC, and the whose-PC menu
-- reaches that through <PLAYER>'s PC (src/ui/gen2/ItemPcMenu.lua), so
-- BILL's PC shows the cart's own five rows. The bedroom's PC keeps it: the
@@ -156,6 +172,91 @@ function PcMenu:close()
if self.onClose then self.onClose(self.changedDecorations) end
end
-- engine/pokemon/bills_pc.asm:2403 BillsPC_ChangeBoxSubmenu .Switch
-- engine/menus/save.asm:40 ChangeBoxSaveGame
function PcMenu:beginChangeBox(index)
self.changeBox = index
self.savePhase = "confirm"
self.saveChoice = 1
self.saveTimer = 0
self.saved = nil
local existed = self.saveExists
if existed == nil then existed = Save.exists("gold") end
self.existed = existed
end
-- .refused: `pop de / ret`, with wCurBox untouched and the picker still up.
function PcMenu:refuseChangeBox()
self.savePhase, self.changeBox = nil, nil
self.saveTimer = 0
end
function PcMenu:acceptChangeBox()
if self.saveChoice == 2 then return self:refuseChangeBox() end
if self.savePhase == "confirm" and self.existed then
self.savePhase = "overwrite"
self.saveChoice = 1
return
end
self.savePhase = "saving"
self.saveTimer = 0
end
-- `pop de / ld a, e / ld [wCurBox], a` sits between SaveBox and
-- SavingDontTurnOffThePower, so the new index rides the file that is written.
function PcMenu:writeChangeBox()
Boxes.setCurrent(self.save, self.changeBox)
local ok = self.writer(self.save)
self.saved = ok and true or false
if ok then SaveMenu.playSaveSfx(self.game, SaveMenu.SFX_SAVE) end
end
function PcMenu:savePrompt()
if self.savePhase == "overwrite" then return SaveMenu.OVERWRITE_PROMPT end
if self.savePhase == "saving" then return SaveMenu.SAVING_PROMPT end
if self.savePhase == "done" then
if self.saved then
local name = (self.save.player and self.save.player.name) or "GOLD"
return { name .. " saved", "the game." }
end
return { "Could not save.", "" }
end
return CHANGE_BOX_SAVE
end
function PcMenu:updateChangeBox()
-- SavingDontTurnOffThePower is DelayFrames, not a prompt: no button does
-- anything until the sequence runs out (engine/menus/save.asm:55).
if self.savePhase == "saving" then
self.saveTimer = self.saveTimer + 1
if self.saveTimer >= SaveMenu.SAVING_FRAMES then
self:writeChangeBox()
self.savePhase = "done"
self.saveTimer = 0
end
return
end
if self.savePhase == "done" then
self.saveTimer = self.saveTimer + 1
if self.saveTimer >= SaveMenu.SAVED_FRAMES then
self.savePhase, self.changeBox = nil, nil
self.picking = false
end
return
end
local input = self.game and self.game.input
if not input then return end
if input:wasPressed("up") or input:wasPressed("down") then
self.saveChoice = self.saveChoice == 1 and 2 or 1
elseif input:wasPressed("a") then
self:acceptChangeBox()
elseif input:wasPressed("b") then
-- B out of a yes/no is NO (InterpretTwoOptionMenu returns carry).
self:refuseChangeBox()
end
end
function PcMenu:choose()
local entry = self.entries[self.index]
if not entry then return end
@@ -231,6 +332,11 @@ function PcMenu:update(_dt)
return
end
if self.savePhase then
self:updateChangeBox()
return
end
if self.picking then
local total = Boxes.NUM_BOXES
if input:wasPressed("up") then
@@ -238,8 +344,11 @@ function PcMenu:update(_dt)
elseif input:wasPressed("down") then
self.pickIndex = self.pickIndex < total and self.pickIndex + 1 or 1
elseif input:wasPressed("a") then
Boxes.setCurrent(self.save, self.pickIndex)
self.picking = false
if self.pickIndex == (self.save.currentBox or 1) then
self.picking = false
else
self:beginChangeBox(self.pickIndex)
end
elseif input:wasPressed("b") then
self.picking = false
end
@@ -287,6 +396,22 @@ function PcMenu:drawPanel()
("%d/%d"):format(Boxes.count(self.save, i), Boxes.MONS_PER_BOX),
18, ty)
end
if self.savePhase then
-- ChangeBoxSaveGame's MenuTextbox, then YesNoBox over the box list.
Chrome.box(0, 12, 20, 6)
local lines = self:savePrompt()
Chrome.print(lines[1] or "", 1, 14)
Chrome.print(lines[2] or "", 1, 16)
if self.savePhase == "confirm" or self.savePhase == "overwrite" then
Chrome.box(YESNO_X, YESNO_Y, YESNO_W, YESNO_H)
Chrome.print("YES", YESNO_X + 2, YESNO_Y + 1)
Chrome.print("NO", YESNO_X + 2, YESNO_Y + 3)
Chrome.cursor(YESNO_X + 1,
YESNO_Y + (self.saveChoice == 1 and 1 or 3))
end
love.graphics.setColor(1, 1, 1, 1)
return
end
Chrome.box(0, 14, 20, 4)
Chrome.print("Which BOX?", 1, 16)
love.graphics.setColor(1, 1, 1, 1)
+13
View File
@@ -2096,10 +2096,23 @@ function Pokegear:mapCursorSprite(x, y)
Chrome.cursor(math.floor(x / 8), math.floor(y / 8))
end
-- PokegearRadio_Init's tile $08 at `depixel 4, 10, 4, 4`, three rows deep
-- (data/sprite_anims/oam.asm:588), x = knob (pokegear.asm:1355).
function Pokegear:drawTuningKnob()
self:loadArrowSheet()
if not (self.arrow and self.arrow:available()) then return end
local row = self:currentStation()
local tx = (72 + (row and row.knob or 0)) / 8
self.arrow:draw(0x08, tx, 1)
self.arrow:draw(0x08, tx, 2)
self.arrow:draw(0x08, tx, 3)
end
function Pokegear:drawRadio()
self:ensureTuned()
self:drawTilemap(self.gfx and self.gfx.cards and self.gfx.cards.radio)
self:drawStrip()
self:drawTuningKnob()
local station = self:currentStation()
-- UpdateRadioStation prints the tuned channel's name at (2,9). Dead air
-- prints nothing: NoRadioStation clears the box and leaves it clear.
+19 -4
View File
@@ -54,6 +54,11 @@ local TIME_X, TIME_Y = 13, 8
local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 0, 7, 6, 5
-- AlreadyASaveFileText (AskOverwriteSaveFile, engine/menus/save.asm:47) and
-- SavingDontTurnOffThePower's own line, shared with the PC's CHANGE BOX save.
local OVERWRITE_PROMPT = { "There is already a", "save file. Is it" }
local SAVING_PROMPT = { "SAVING… DON'T TURN", "OFF THE POWER." }
function SaveMenu:wantsFillScale() return true end
function SaveMenu:drawsWidescreen() return true end
@@ -75,14 +80,18 @@ function SaveMenu.new(game, opts)
return self
end
function SaveMenu:playSfx(id)
local data = self.game and self.game.data
function SaveMenu.playSaveSfx(game, id)
local data = game and game.data
local audio = data and data.audio
if not (audio and audio.sfxOrder) then return end
local name = audio.sfxOrder[id + 1]
if name and audio.sfx and audio.sfx[name] then Sound.play(data, name) end
end
function SaveMenu:playSfx(id)
SaveMenu.playSaveSfx(self.game, id)
end
function SaveMenu:finish(saved)
if self.onDone then self.onDone(saved) end
end
@@ -162,10 +171,10 @@ function SaveMenu:prompt()
if self.phase == "overwrite" then
-- AlreadyASaveFileText when the file is this player's; AnotherSaveFileText
-- when the ID differs. Only the first can happen here.
return { "There is already a", "save file. Is it" }
return OVERWRITE_PROMPT
end
if self.phase == "saving" then
return { "SAVING… DON'T TURN", "OFF THE POWER." }
return SAVING_PROMPT
end
if self.phase == "done" then
if self.saved then
@@ -226,4 +235,10 @@ function SaveMenu:drawWidescreen(winW, winH)
G.pop()
end
SaveMenu.SFX_SAVE = SFX_SAVE
SaveMenu.SAVING_FRAMES = SAVING_FRAMES
SaveMenu.SAVED_FRAMES = SAVED_FRAMES
SaveMenu.OVERWRITE_PROMPT = OVERWRITE_PROMPT
SaveMenu.SAVING_PROMPT = SAVING_PROMPT
return SaveMenu
+6 -10
View File
@@ -63,6 +63,7 @@ local Chrome = require("src.ui.gen2.Chrome")
local Font = require("src.render.Font")
local GbcPalette = require("src.render.GbcPalette")
local HpBar = require("src.battle.gen2.HpBar")
local ItemEffects = require("src.core.gen2.ItemEffects")
local Mon = require("src.battle.gen2.Mon")
local Palettes = require("src.world.gen2.Palettes")
local Pokerus = require("src.core.gen2.Pokerus")
@@ -109,15 +110,6 @@ local TYPE_NAMES = {
CURSE_TYPE = "???",
}
-- PlaceStatusString (engine/pokemon/mon_stats.asm): three letters, and a mon
-- with no HP reads FNT whatever its status byte says. Same table the party
-- list uses; both screens call the same routine on the cart.
local STATUS_STRING = {
slp = "SLP", psn = "PSN", brn = "BRN", frz = "FRZ", par = "PAR",
poison = "PSN", burn = "BRN", freeze = "FRZ", paralysis = "PAR",
sleep = "SLP", toxic = "PSN",
}
-- Gen 2 pics are 5x5, 6x6 or 7x7 and PadFrontpic centres the small ones in
-- the 7x7 block PrepMonFrontpic lays at hlcoord 0, 0. Same table the dex
-- uses, for the same reason.
@@ -208,11 +200,15 @@ local function levelText(level)
return "<LV>" .. tostring(level)
end
-- PlaceStatusString (engine/pokemon/mon_stats.asm): three letters, and a mon
-- with no HP reads FNT whatever its status byte says. Same lookup the party
-- list makes; both screens call the same routine on the cart.
local function statusText(mon)
if (mon.hp or 0) <= 0 then return "FNT" end
local status = mon.status
if not status then return nil end
return STATUS_STRING[tostring(status):lower()]
local class = ItemEffects.STATUS_CLASS[tostring(status):lower()]
return class and class:upper()
end
-- wTempMonPokerusStatus is one byte: the low nibble counts the days left and
+9 -1
View File
@@ -105,8 +105,16 @@ function NPC:update(map, entities)
end
end
-- UpdateNPCSprite branches to NotYetMoving while BIT_FONT_LOADED is set
-- -- engine/overworld/movement.asm:139
local function textBoxUp()
local stack = require("src.core.Game").stack
local top = stack and stack.top and stack:top()
return top ~= nil and not top.isOverworld
end
function NPC:walkPhase()
if not self.moving then return 0 end
if not self.moving or textBoxUp() then return 0 end
-- engine/overworld/movement.asm:301
local p = (self.animClock or 0) % 16
return (p >= 4 and p < 12) and 1 or 0
+42 -2
View File
@@ -20,6 +20,7 @@ local Player = require("src.world.Player")
local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens")
local ScriptRunner = require("src.script.ScriptRunner")
local Theme = require("src.ui.Theme")
local Tilt = require("src.render.Tilt")
local TextBox = require("src.render.TextBox")
local Transition = require("src.render.Transition")
@@ -1109,6 +1110,13 @@ function OverworldState:update(dt)
end
end
-- EnterMapAnim's .done tail re-enables the companion once the swoop or the
-- spin-down has landed (player_animations.asm:40)
if self.pikachuWarpHidden and not (self.flyAnim or self.flyArrive
or self.teleportOut or self.transitioning or self.player.spinning) then
self:showPikachuAfterWarp()
end
-- Dig/Teleport/Escape-Rope departure spin (beginTeleportOut). The sprite
-- spins UP out of the map before the fade (player_animations.asm
-- _LeaveMapAnim -> PlayerSpinWhileMovingUp + SFX_TELEPORT_EXIT_1), the
@@ -1190,7 +1198,9 @@ function OverworldState:update(dt)
or self.engaging or self.emote or self.teleportOut
or self.flyAnim or self.flyArrive
end
if not scripted and not self.transitioning then
-- a scriptMove's onDone can push a text box on the frame it retires, and
-- DisplayTextID owns the loop from there (home/text_script.asm:3)
if not scripted and not self.transitioning and Game.stack:top() == self then
self:handleInput()
end
@@ -1716,6 +1726,28 @@ function OverworldState:syncSurfingPikachu()
p.surfingPikachu = mon ~= nil and mon.species == "PIKACHU" or false
end
-- _LeaveMapAnim drops the companion's sprite before the animation starts and
-- EnterMapAnim only puts it back once landed -- home/pikachu.asm:1, :10
function OverworldState:hidePikachuForWarp()
self.pikachuWarpHidden = true
require("src.world.PikachuFollower").setVisible(self, false)
end
function OverworldState:showPikachuAfterWarp()
self.pikachuWarpHidden = nil
self.pikachuTrail = { x = self.player.cellX, y = self.player.cellY }
local Follower = require("src.world.PikachuFollower")
local npc = Follower.current(self)
if npc then
npc.cellX, npc.cellY = self.player.cellX, self.player.cellY
npc.px, npc.py = npc.cellX * 16, npc.cellY * 16
npc.targetX, npc.targetY = nil, nil
npc.goalX, npc.goalY = nil, nil
npc.moving = false
end
Follower.setVisible(self, true)
end
-- The rejection loop shared by the Good and Super Rods
-- (item_effects.asm ItemUseGoodRod .RandomLoop / ReadSuperRodData): an
-- odd random byte is no bite; otherwise a 2-bit pick rerolls until it
@@ -1822,6 +1854,7 @@ function OverworldState:flyTo(mapId)
Game.save.forcedBike = nil -- HandleFlyWarpOrDungeonWarp res BIT_ALWAYS_ON_BIKE
self.player.surfing = false
self:syncSurfingPikachu()
self:hidePikachuForWarp()
-- _LeaveMapAnim .flyAnimation: the bird flaps in place (8 x Delay3),
-- then SFX_FLY and the up-right path, a 40-frame beat off screen, and
-- the exit over the top-left -- the warp fades only once the bird is
@@ -1852,6 +1885,7 @@ function OverworldState:beginTeleportOut(onDone)
require("src.core.Sound").play(Game.data, "Teleport_Exit1")
self.player.surfing = false
self:syncSurfingPikachu()
self:hidePikachuForWarp()
self.player.inputLocked = true
-- rising spin: the mirror of the arrival spin-drop set in startWarpTo, so
-- spinRise lifts the sprite (Player:pose) while spinFrames counts down
@@ -3045,6 +3079,7 @@ function OverworldState:nurseHeal(onDone, npc)
end
-- Yellow's companion has its own beat threaded through this sequence
local Follower = require("src.world.PikachuFollower")
-- YesNoChoicePokeCenter draws HEAL/CANCEL, not YES/NO (home/yes_no.asm:21)
Game.stack:push(TextBox.new(Game, hello, nil, { choice = function(yes)
if not yes then
Game.stack:push(TextBox.new(Game, bye, onDone))
@@ -3093,7 +3128,7 @@ function OverworldState:nurseHeal(onDone, npc)
end
end))
end)
end }))
end, choiceLabels = { "HEAL", "CANCEL" }, choiceBox = Theme.healCancelBox }))
end
-- pokecenter.asm bows the nurse between the two PrintText calls (#995)
@@ -4526,6 +4561,11 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
-- down, so the player is never drawable mid-fade nor standing bare on the
-- landing frame (#916)
self.playerHidden = false
-- setMap respawned a follower under the player; _LeaveMapAnim's Func_1510
-- suppression runs until EnterMapAnim lands (home/pikachu.asm:1)
if self.pikachuWarpHidden then
require("src.world.PikachuFollower").setVisible(self, false)
end
-- The warp we land ON stays inert for the completed-step check until we
-- physically step off it, so a warp whose destination cell is itself a
-- warp cannot bounce us straight back (elevator cars, stacked stair/door
+9
View File
@@ -226,7 +226,16 @@ function Player:facingCell()
return Collision.target(self.cellX, self.cellY, self.facing)
end
-- UpdatePlayerSprite jumps to .notMoving while BIT_FONT_LOADED is set
-- -- engine/overworld/movement.asm:57
local function textBoxUp()
local stack = require("src.core.Game").stack
local top = stack and stack.top and stack:top()
return top ~= nil and not top.isOverworld
end
function Player:walkPhase()
if textBoxUp() then return 0 end
-- moving, the land-frame after a completed step, or an active wall-bonk
-- (issue #230) animate; a standing sprite otherwise
if not self.moving and not self.stepLanded
+1 -1
View File
@@ -10,10 +10,10 @@ local PixelCanvas = require("src.render.PixelCanvas")
local MapPreview = {}
-- home/map.asm:1739-1748 LoadTilesetGFX
local ROOF_TILESETS = {
TILESET_JOHTO = true,
TILESET_JOHTO_MODERN = true,
TILESET_KANTO = true,
}
local function applyRoofOverlay(atlasPath, roofPath, tilesPerRow)
+58 -6
View File
@@ -117,6 +117,15 @@ local VAR = {
SPECIALPHONECALL = 0x14,
}
-- constants/ram_constants.asm:293 wPlayerState. PLAYER_SKATE (2) has no row:
-- nothing writes it, and FieldMoves has no string for it either.
local PLAYER_STATE_BY_ID = {
[0] = FieldMoves.PLAYER_NORMAL,
[1] = FieldMoves.PLAYER_BIKE,
[4] = FieldMoves.PLAYER_SURF,
[8] = FieldMoves.PLAYER_SURF_PIKA,
}
local BATTLETYPE = {
CANLOSE = 1,
FORCESHINY = 7,
@@ -5207,6 +5216,7 @@ function World:dropMapImages(mapId)
if key:sub(1, #prefix) == prefix then store[key] = nil end
end
end
if self.connectionMaps then self.connectionMaps[mapId] = nil end
end
-- LoadMapAttributes' refill, for every map the session has edited. Neighbour
@@ -7480,13 +7490,10 @@ function World:zoomScale()
return Zoom.scale(self:fitScale())
end
-- Outdoor Johto/Kanto town tilesets are the only ones that swap in roof
-- tiles $0a-$12 (mapgroup_roofs.asm). Applying roofs to indoor tilesets
-- in the same map group (lab, houses) corrupts their GFX.
-- home/map.asm:1739 LoadTilesetGFX
local ROOF_TILESETS = {
TILESET_JOHTO = true,
TILESET_JOHTO_MODERN = true,
TILESET_KANTO = true,
}
function World:atlasFor(mapDef)
@@ -8724,6 +8731,44 @@ function World:spawnFacing()
end
end
-- A neighbour map, built once and kept for its collision alone: the seam
-- queries below run on the per-step path.
function World:connectionMap(mapId)
self.connectionMaps = self.connectionMaps or {}
local cached = self.connectionMaps[mapId]
if cached ~= nil then return cached or nil end
local def = self.maps[mapId]
local tileset = def and self.tilesets[def.tileset]
local map = (def and tileset) and Map.new(def, tileset) or false
self.connectionMaps[mapId] = map
return map or nil
end
-- home/map.asm:1908 GetMovementPermissions
function World:cellCollisionAcross(map, cx, cy)
if map:inBounds(cx, cy) then return map:cellCollision(cx, cy) end
local dir
if cy < 0 then dir = "up"
elseif cy >= map.heightCells then dir = "down"
elseif cx < 0 then dir = "left"
elseif cx >= map.widthCells then dir = "right"
end
local conn = dir and map:connection(DIR_CONN[dir])
local dest = conn and conn.mapId and self.maps[conn.mapId]
local destMap = dest and self:connectionMap(conn.mapId)
if destMap then
local x, y = Map.connectionLanding(dest, conn, dir, cx, cy)
local vertical = dir == "up" or dir == "down"
local want = (vertical and cx or cy) - (conn.offset or 0) * 2
-- connectionLanding clamps into the destination; past the end of the strip
-- the buffer still holds this map's own border block
if x and (vertical and x or y) == want then
return destMap:cellCollision(x, y)
end
end
return map:cellCollision(cx, cy)
end
-- Seamless edge cross: swap map data, park the player one cell before the
-- landing (same world pixels the neighbor strip already showed), and keep
-- the step running so the seam does not hitch.
@@ -8736,7 +8781,13 @@ function World:tryConnection(dir)
local x, y = Map.connectionLanding(
dest, conn, dir, self.player.cellX, self.player.cellY)
if not x then return false end
local destMap = Map.new(dest, self.tilesets[dest.tileset])
local destMap = self:connectionMap(conn.mapId)
if not destMap then return false end
-- home/map.asm:1946 GetMovementPermissions side-wall arm
if dir == "down"
and Permissions.neighborBlocksDown(dir, destMap:cellCollision(x, y)) then
return false
end
-- A surfing crossing lands on water, which isWalkable refuses; the arm the
-- step would have taken is what decides, the same as it does inside the map.
local landable
@@ -8869,7 +8920,8 @@ function World:movePlayer(dir)
-- .CheckLandPerms and .CheckSurfPerms read the same wTilePermissions, so the
-- veto applies to walking and surfing alike.
local permitted = Permissions.stepPermitted(
function(x, y) return map:cellCollision(x, y) end, p.cellX, p.cellY, dir)
function(x, y) return self:cellCollisionAcross(map, x, y) end,
p.cellX, p.cellY, dir)
-- `.CheckNPC`'s IsNPCAtCoord answers for a BIG_OBJECT's whole 2x2 blob, and
-- Player:tryMove's entity scan only ever compares the one cell an object
-- stands on -- so the three cells the Vermilion Snorlax overhangs are vetoed
+112
View File
@@ -0,0 +1,112 @@
-- Driver: Fly / Teleport must take Pikachu with it (#1400). _LeaveMapAnim
-- drops the companion sprite before the bird flaps (home/pikachu.asm:1) and
-- EnterMapAnim only puts it back once the swoop or the spin has landed, so
-- Pikachu is invisible for the whole sequence and never stands on the landing
-- cell ahead of the player.
--
-- POKEPORT_DRIVER=tests/drivers/fly_pikachu_bug1400_test.lua \
-- POKEPORT_VERSION=yellow POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
--
-- Never add POKEPORT_SPEED; the run needs an imported Yellow cache.
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local GameVersion = require("src.core.GameVersion")
local PF = require("src.world.PikachuFollower")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
check("running as Yellow (needs POKEPORT_VERSION=yellow)",
GameVersion.isYellow())
game.save.flags = game.save.flags or {}
game.save.flags.EVENT_GOT_STARTER = true
game.save.flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = true
-- DisablePikachuOverworldSpriteDrawing is what keeps it in the ball
-- (pokeyellow scripts/OaksLab.asm); out of the ball is what follows (#1009)
game.save.pikachuInBall = false
game.save.party = { Pokemon.new(game.data, "PIKACHU", 20) }
game.save.onBike = false
local function drawn()
local ow = game.overworld
local npc = ow and PF.current(ow)
if not npc then return false end
for _, e in ipairs(ow.entities or {}) do
if e == npc then return true end
end
return false
end
U.teleport(game, "ROUTE_17", 4, 10, "down")
U.wait(30)
local ow = game.stack:top()
check("Pikachu is out and drawn before the flight", drawn())
U.shot(game, DIR .. "/bug1400_0_before.png")
ow:flyTo("PALLET_TOWN")
U.wait(12) -- mid in-place flap
check("hidden during the departure flap", not drawn())
U.shot(game, DIR .. "/bug1400_1_flap.png")
U.wait(60) -- the bird's path out
check("still hidden while the bird flies off", not drawn())
local guard = 0
while ow.map.id == "ROUTE_17" and guard < 900 do
guard = guard + 1
coroutine.yield()
end
guard = 0
while not ow.flyArrive and guard < 900 do
guard = guard + 1
coroutine.yield()
end
U.wait(12) -- mid swoop, the frame the old bug showed Pikachu already landed
check("hidden through the arrival swoop", not drawn())
U.shot(game, DIR .. "/bug1400_2_arrive.png")
guard = 0
while ow.flyArrive and guard < 900 do
guard = guard + 1
coroutine.yield()
end
U.wait(10)
check("back out once the bird has landed", drawn())
do
local npc = PF.current(ow)
local p = ow.player
check("and it comes back on the player's own cell",
npc and p and npc.cellX == p.cellX and npc.cellY == p.cellY)
end
U.shot(game, DIR .. "/bug1400_3_landed.png")
-- Dig / Teleport / Escape Rope take the same _LeaveMapAnim path
game.save.lastHeal = { map = "VIRIDIAN_CITY", x = 23, y = 26 }
ow:beginTeleportOut()
U.wait(20)
check("hidden during the teleport-out spin", not drawn())
U.shot(game, DIR .. "/bug1400_4_spin_out.png")
guard = 0
while (ow.teleportOut or ow.player.spinning or ow.transitioning)
and guard < 900 do
guard = guard + 1
coroutine.yield()
end
U.wait(10)
check("back out once the arrival spin has landed", drawn())
U.shot(game, DIR .. "/bug1400_5_spin_in.png")
U.log("Watch the shots in order: Pikachu stands beside the player before")
U.log("the flight, is nowhere on screen for the flap, the fade and the")
U.log("swoop, and only reappears under him once the bird sets him down.")
U.log("A Pikachu standing on the landing cell during the swoop is the bug.")
U.log("The pad is yours -- fly around and watch the departures.")
while true do coroutine.yield() end
end
+99
View File
@@ -0,0 +1,99 @@
-- #1401: $d9/$da battlergfx loaded the wrong row count
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1401_test.lua love .
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
-- data/moves/animations.asm:1840 Growl, :3605 Icy Wind
local CASES = {
{ move = "GROWL", cmd = "battlergfx_2row", rows = 1, head = 7, feet = 6 },
{ move = "ICY_WIND", cmd = "battlergfx_1row", rows = 2, head = 14, feet = 12 },
}
local function sheet(runner, gfx)
for _, entry in ipairs(runner.loaded or {}) do
if entry.gfx == gfx then return entry end
end
return nil
end
local function liftedStrip(runner)
local structs = runner.objects and runner.objects.structs or {}
for slot = 1, #structs do
local id = structs[slot].objectId
if type(id) == "string" and id:match("PLAYERHEAD") then return id end
if type(id) == "string" and id:match("ENEMYFEET") then return id end
end
return nil
end
return function(game)
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
game.save.party = { Mon.new(game.data, "CYNDAQUIL", 30) }
assert(world:startBattle({ wild = Mon.new(game.data, "PIDGEY", 30) }),
"startBattle failed")
local battle
for _ = 1, 600 do
local top = game.stack:top()
if top and top.battle then battle = top break end
U.wait(1)
end
assert(battle and battle.battle, "battle screen is not on the stack")
assert(battle.anims and battle.anims.scripts,
"battle_anims.lua has no scripts -- re-import Gold")
local failed = false
for _, case in ipairs(CASES) do
battle.anim = nil
if not battle:animForMove(case.move, "player") then
U.log("FAIL no animation for", case.move)
failed = true
else
local head, feet
for _ = 1, 400 do
local runner = battle.anim
if not runner then break end
head = head or sheet(runner, "BATTLE_ANIM_GFX_PLAYERHEAD")
feet = feet or sheet(runner, "BATTLE_ANIM_GFX_ENEMYFEET")
U.wait(1)
end
if not (head and feet) then
U.log("FAIL", case.move, "never loaded the battler sheets")
failed = true
else
U.log(("%-9s %-16s rows=%d head=%d feet=%d (want rows=%d head=%d feet=%d)")
:format(case.move, case.cmd, head.rows, head.tiles, feet.tiles,
case.rows, case.head, case.feet))
if head.rows ~= case.rows or head.tiles ~= case.head
or feet.tiles ~= case.feet then
U.log("FAIL", case.move, "loaded the wrong row count")
failed = true
end
end
end
end
U.log(failed and "FAIL #1401" or "PASS #1401 battlergfx rows follow the jumptable")
U.log("look: Growl lifts ONE row of the Cyndaquil's own tiles, not two")
battle.anim = nil
battle:animForMove("GROWL", "player")
for _ = 1, 400 do
if not battle.anim then break end
local strip = liftedStrip(battle.anim)
if strip then
U.log("parked on", strip)
break
end
U.wait(1)
end
while true do
coroutine.yield()
end
end
+161
View File
@@ -0,0 +1,161 @@
-- #1421: the Gold battle HUD ran ahead of its own animations.
--
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1421_test.lua love .
-- POKEPORT_SHOT_DIR=/tmp/gold-bug1421 (default)
--
-- Two symptoms, one seam, and neither can be asserted -- both are "what is on
-- screen while an animation runs":
--
-- caught a wild mon that is NOT in the dex is caught with a MASTER BALL.
-- The caught mark ($5d at (1,1)) must be absent for every frame of
-- the throw and every line after it, because DrawEnemyHUDBorder --
-- the only thing that paints it -- is never called again during a
-- capture (engine/battle/trainer_huds.asm:134-151). The control
-- run right after it re-enters a battle with the same species now
-- in the dex, where the mark IS there from the first frame.
--
-- status THUNDER WAVE on the enemy. PAR may not be on the HUD until the
-- animation and the "is paralyzed!" line are done with, which is
-- where UpdateBattleHuds runs (home/battle.asm:150).
--
-- Every frame of both animations is logged as `live` (the engine's byte, a
-- whole turn ahead) against `hud` (what the HUD is allowed to print), so the
-- lag is a column of text as well as a strip of pictures. The run ends in a
-- third wild battle with THUNDER WAVE under the cursor: press A, A, A and
-- watch the tag land with its own line.
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
local OUT = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1421"
local WILD = "PIDGEY"
local hostVisible = love.visible
love.visible = function(v) if hostVisible then pcall(hostVisible, v) end end
local function tap(game, button, frames)
game.input.pressQueue[#game.input.pressQueue + 1] = button
game.input.state[button] = true
U.wait(2)
game.input.state[button] = false
U.wait(frames or 6)
end
local function openBattle(game, opts)
assert(game.world:startBattle(opts), "startBattle failed")
for _ = 1, 900 do
local top = game.stack:top()
if top and top.battle then return top end
U.wait(1)
end
error("battle screen never came up")
end
local function toMenu(game, screen)
for _ = 1, 400 do
if screen.phase == "menu" then return true end
if screen.battle.over then return false end
tap(game, "a", 2)
end
return screen.phase == "menu"
end
local function giveMoves(game, mon, moves)
mon.moves = {}
for i, id in ipairs(moves) do
local def = assert(game.data.moves[id], id .. " is not in moves.lua")
mon.moves[i] = { id = id, pp = def.pp, maxPp = def.pp }
end
return mon
end
-- Shoot and log while the screen is busy: `probe` returns the two values the
-- run is about, and the caller gets them for every frame.
local function watch(game, screen, prefix, label, probe, limit)
local frames = 0
while frames < (limit or 240) do
if frames % 3 == 0 then
U.shot(game, ("%s-%03d.png"):format(prefix, frames))
local live, hud = probe()
U.log(("[driver] %s f%03d live=%s hud=%s")
:format(label, frames, tostring(live), tostring(hud)))
end
if not screen.anim and screen.phase ~= "resolving" then break end
frames = frames + 1
U.wait(1)
end
return frames
end
return function(game)
U.wait(45)
assert(game.world and game.world.map, "gold world did not boot")
local save = game.save
save.pokedex = save.pokedex or { seen = {}, caught = {} }
save.pokedex.caught[WILD] = nil
save.inventory = { MASTER_BALL = 5, POKE_BALL = 10 }
save.boxes = nil
save.party = { giveMoves(game, Mon.new(game.data, "CYNDAQUIL", 30),
{ "THUNDER_WAVE", "TACKLE" }) }
------------------------------------------------------------------ caught
local screen = openBattle(game, { wild = Mon.new(game.data, WILD, 5) })
assert(toMenu(game, screen), "never reached the battle menu")
U.shot(game, OUT .. "/caught-00-menu.png")
U.log(("[driver] caught: dex before = %s, hud latch = %s")
:format(tostring(save.pokedex.caught[WILD]), tostring(screen.caughtMark)))
screen:useItem("MASTER_BALL")
watch(game, screen, OUT .. "/caught-01-throw", "caught",
function()
return save.pokedex.caught[WILD] and true or false, screen.caughtMark
end, 200)
-- The lines that follow the throw: "Gotcha!", the dex entry, the nickname
-- prompt. The mark may not appear on any of them either.
for i = 0, 7 do
U.shot(game, ("%s/caught-02-after-%d.png"):format(OUT, i))
if screen.phase == "ask-nickname" then tap(game, "b", 4)
else tap(game, "a", 4) end
if screen.phase == "done" or not game.stack:top() then break end
end
U.log(("[driver] caught: dex after = %s, hud latch = %s")
:format(tostring(save.pokedex.caught[WILD]), tostring(screen.caughtMark)))
for _ = 1, 300 do
if game.world and not (game.stack:top() or {}).battle then break end
tap(game, "a", 2)
end
------------------------------------------------------------------ control
-- Same species, now in the dex: the mark IS on the HUD from the first frame
-- the enemy HUD is drawn, because DrawEnemyHUDBorder runs at battle start.
save.party = { giveMoves(game, Mon.new(game.data, "CYNDAQUIL", 30),
{ "THUNDER_WAVE", "TACKLE" }) }
screen = openBattle(game, { wild = Mon.new(game.data, WILD, 5) })
assert(toMenu(game, screen), "never reached the battle menu")
U.shot(game, OUT .. "/control-00-mark.png")
U.log(("[driver] control: dex = %s, hud latch = %s")
:format(tostring(save.pokedex.caught[WILD]), tostring(screen.caughtMark)))
------------------------------------------------------------------ status
screen:submit({ kind = "move", move = "THUNDER_WAVE" })
watch(game, screen, OUT .. "/status-01-anim", "status",
function()
local enemy = screen.battle.enemy
return enemy and enemy.status, screen:hudStatus(enemy, "enemy")
end, 240)
U.shot(game, OUT .. "/status-02-line.png")
for i = 0, 4 do
tap(game, "a", 4)
U.shot(game, ("%s/status-03-after-%d.png"):format(OUT, i))
end
U.log(("[driver] status: live=%s hud=%s"):format(
tostring(screen.battle.enemy and screen.battle.enemy.status),
tostring(screen:hudStatus(screen.battle.enemy, "enemy"))))
U.log("[driver] shots in " .. OUT
.. " -- the battle is yours: FIGHT, THUNDER WAVE, watch the tag land")
end
+97
View File
@@ -0,0 +1,97 @@
-- #1425 (and #1424): the PACK's ×NN column.
--
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1425_test.lua love .
-- POKEPORT_SHOT_DIR=/tmp/gold-bug1425 (default)
--
-- Nothing here can be asserted: the bug IS the column a digit lands in.
-- PlaceMenuItemQuantity's `lb bc, 1, 2` (engine/menus/menu_2.asm:24) is a
-- two-digit field with the leading digit blanked, so a ×5 and a ×50 must have
-- their ones digit in the SAME column; the port printed "×5" hard against the
-- cross. Each pocket is seeded with a one-digit and a two-digit count stacked
-- next to each other so the two rows can be read off against one another, and
-- the TM pocket is here because its rows used to print no count at all.
--
-- The run ends with the PACK still open on the TM pocket, so a human takes the
-- controls exactly where the screenshots stop.
local U = require("tests.drivers.util")
local Bag = require("src.inventory.Bag")
local PackMenu = require("src.ui.gen2.PackMenu")
-- One-digit and two-digit counts side by side in every pocket that shows one.
local SEED = {
{ "POTION", 5 },
{ "SUPER_POTION", 50 },
{ "ANTIDOTE", 1 },
{ "FULL_HEAL", 99 },
{ "POKE_BALL", 7 },
{ "GREAT_BALL", 12 },
{ "TM_DYNAMICPUNCH", 1 },
{ "TM_HEADBUTT", 3 },
{ "TM_THUNDER", 24 },
{ "HM_CUT", 1 },
{ "HM_SURF", 1 },
}
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1425"
local function shot(name)
U.wait(3)
U.shot(game, ("%s/%s.png"):format(out, name))
end
U.wait(45)
assert(game.world and game.world.map, "gold world did not boot")
local save = game.save
save.inventory = {}
save.bagOrder = {}
for _, entry in ipairs(SEED) do
local id, count = entry[1], entry[2]
if not (game.data.items and game.data.items[id]) then
U.log("[driver] SKIP", id, "-- not in this cache")
else
save.inventory[id] = count
table.insert(Bag.order(save), id)
end
end
local pack = PackMenu.new(game, { save = save, world = game.world,
onClose = function() end })
game.stack:push(pack)
shot("00-items") -- ×5 over ×50: the ones digits line up
U.tap(game, "right")
shot("01-balls") -- ×7 over ×12
U.tap(game, "right")
shot("02-key-items") -- no counts at all here
U.tap(game, "right")
shot("03-tmhm") -- TMs carry ×NN, the two HMs carry none
-- SELECT arms the row instead of registering it (#1427): the hollow ▷ marks
-- TM_HEADBUTT while "Where should this be moved to?" holds the box.
U.tap(game, "down")
U.tap(game, "select")
shot("04-tmhm-armed")
U.tap(game, "up")
shot("05-tmhm-destination")
U.tap(game, "a")
shot("06-tmhm-moved") -- HEADBUTT is now the first TM row
U.log("[driver] bag order:", table.concat(Bag.order(save), " "))
-- Back to the ITEM pocket and into a TOSS, whose own box prints the count
-- with leading zeros (×01, not × 1).
U.tap(game, "left")
U.tap(game, "left")
U.tap(game, "left")
U.tap(game, "a")
U.tap(game, "down")
U.tap(game, "down")
U.tap(game, "a")
shot("07-toss-quantity")
U.log("[driver] shots in " .. out .. " -- the PACK is yours")
end
+174
View File
@@ -0,0 +1,174 @@
-- #1428: the Gold battle HUD never drew the party ball rows.
--
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1428_test.lua love .
-- POKEPORT_SHOT_DIR=/tmp/gold-bug1428 (default)
--
-- BattleStart_TrainerHuds (engine/battle/trainer_huds.asm:1-9) runs from
-- inside BattleStartMessage, so the rows are on screen UNDER the opening line
-- and nowhere else: the player's six always, the opponent's only outside a
-- wild battle. EnemySwitch_TrainerHud (:11-15) brings the opponent's row
-- back for the "will you switch?" prompt after one of its mons drops.
--
-- The party is seeded so all four staged tiles are on screen at once
-- (StageBallTilesData, :47-99): healthy, statused, fainted, and the empty
-- slots past the party count.
--
-- trainer the intro line, both rows, then the shift prompt after the first
-- enemy mon faints -- the opponent's row again, one ball darkened
-- wild the same intro with only the player's row, per the `dec a / ret z`
--
-- Nothing here is assertable: the fix IS twelve sprites. The run ends on the
-- trainer battle's own menu with a human holding the controls.
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
local Trainers = require("src.world.gen2.Trainers")
local OUT = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1428"
local hostVisible = love.visible
love.visible = function(v) if hostVisible then pcall(hostVisible, v) end end
local function tap(game, button, frames)
game.input.pressQueue[#game.input.pressQueue + 1] = button
game.input.state[button] = true
U.wait(2)
game.input.state[button] = false
U.wait(frames or 6)
end
local function openBattle(game, opts)
assert(game.world:startBattle(opts), "startBattle failed")
for _ = 1, 900 do
local top = game.stack:top()
if top and top.battle then return top end
U.wait(1)
end
error("battle screen never came up")
end
-- Four healthy, one poisoned, one fainted, and one slot left empty: every
-- tile StageBallTilesData can stage, in one row. The lead is handed a
-- damaging move outright, because slot 1 of its level-up set is LEER.
local function seedParty(game)
local party = {}
for i = 1, 5 do
party[i] = Mon.new(game.data, "CYNDAQUIL", 20 + i)
end
party[1].moves = {}
for i, id in ipairs({ "EMBER", "TACKLE" }) do
local def = assert(game.data.moves[id], id .. " is not in moves.lua")
party[1].moves[i] = { id = id, pp = def.pp, maxPp = def.pp }
end
party[4].status = "poison"
party[5].hp = 0
return party
end
local function rowState(screen)
local rows = screen.ballRows or {}
return ("player=%s enemy=%s balls=%s"):format(
tostring(rows.player), tostring(rows.enemy),
tostring(screen.hud and screen.hud:image("balls") ~= nil))
end
return function(game)
U.wait(45)
assert(game.world and game.world.map, "gold world did not boot")
local save = game.save
save.inventory = {}
save.party = seedParty(game)
--------------------------------------------------------------- trainer
local entry = game.world:trainerParty(36, 1) -- BUG_CATCHER member 1
assert(entry, "no BUG_CATCHER member 1 in trainers.lua")
entry.party = Trainers.party(game.data, entry)
local screen = openBattle(game, { trainer = entry })
-- The opening line: both rows, both borders, and no names or bars yet.
-- Not before the intro slide has settled, or the shots catch the pics
-- mid-transform instead of the HUD.
U.wait(150)
for i = 0, 6 do
U.shot(game, ("%s/trainer-00-intro-%d.png"):format(OUT, i))
U.log("[driver] intro " .. i .. ": " .. rowState(screen))
U.wait(6)
end
U.log(("[driver] OT party = %d"):format(#(screen.battle.enemyParty or {})))
-- Page through the pic slide and the send-out: the rows go with the OAM the
-- send-out animation takes over.
for i = 0, 7 do
tap(game, "a", 5)
U.shot(game, ("%s/trainer-01-sendout-%d.png"):format(OUT, i))
end
for _ = 1, 400 do
if screen.phase == "menu" then break end
tap(game, "a", 2)
end
U.shot(game, OUT .. "/trainer-02-menu.png")
U.log("[driver] menu: " .. rowState(screen))
--------------------------------------------------------------- shift prompt
-- Drop the enemy's lead so HandleEnemySwitch offers the switch: its row is
-- redrawn for the prompt, with the fainted ball darkened.
if #(screen.battle.enemyParty or {}) > 1 then
for _ = 1, 400 do
if screen.phase == "ask-shift" or screen.phase == "shift-intro" then
break
end
if screen.battle.over then break end
if screen.phase == "menu" then
local enemy = screen.battle.enemy
if enemy then enemy.hp = 1 end
screen:chooseMenu("fight")
U.wait(2)
screen:chooseMove(1)
U.wait(4)
else
tap(game, "a", 6)
end
end
U.log("[driver] shift prompt: phase=" .. tostring(screen.phase))
for i = 0, 5 do
U.shot(game, ("%s/trainer-03-shift-%d.png"):format(OUT, i))
U.log("[driver] shift " .. i .. ": phase=" .. tostring(screen.phase)
.. " " .. rowState(screen))
U.wait(6)
end
-- NO: the enemy sends its next mon and the row goes with the animation.
tap(game, "down", 4)
tap(game, "a", 4)
for i = 0, 5 do
U.shot(game, ("%s/trainer-04-after-shift-%d.png"):format(OUT, i))
tap(game, "a", 5)
end
else
U.log("[driver] this trainer has one mon; no shift prompt to show")
end
--------------------------------------------------------------- wild
-- ShowPlayerMonsRemaining runs for a wild battle too; the `dec a / ret z`
-- right after it is what keeps the OT row off.
for _ = 1, 600 do
if not (game.stack:top() or {}).battle then break end
tap(game, "a", 2)
end
save.party = seedParty(game)
screen = openBattle(game, { wild = Mon.new(game.data, "PIDGEY", 5) })
U.wait(150)
for i = 0, 6 do
U.shot(game, ("%s/wild-00-intro-%d.png"):format(OUT, i))
U.log("[driver] wild " .. i .. ": " .. rowState(screen))
U.wait(6)
end
for _ = 1, 400 do
if screen.phase == "menu" then break end
tap(game, "a", 2)
end
U.shot(game, OUT .. "/wild-01-menu.png")
U.log("[driver] shots in " .. OUT .. " -- the battle is yours")
end
+142
View File
@@ -0,0 +1,142 @@
-- Driver: the shiny sparkle on a SENT OUT mon (#1431).
--
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1431_test.lua love .
-- POKEPORT_SHOT_DIR=/tmp/gold-bug1431 (default)
--
-- SendOutPlayerMon calls BattleCheckPlayerShininess and replays
-- ANIM_SEND_OUT_MON with wBattleAnimParam 1 -- the script's `.Shiny` arm --
-- between the plain animation and the cry (engine/battle/core.asm:3820-3837);
-- ShowSetEnemyMonAndSendOutAnimation does the same for every enemy send-out
-- (:3364-3383). Only the wild-intro path (BattleStartMessage, :8701-8715) had
-- been ported, so a shiny lead-off, a shiny switch-in and a shiny enemy
-- replacement all came out silent.
--
-- Three send-outs to watch, none of them the wild intro: the player's lead-off,
-- the player's own switch, and the opponent's replacement after a faint.
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
local GbcPalette = require("src.render.GbcPalette")
-- constants/battle_constants.asm: the DV pair BATTLETYPE_FORCESHINY forces.
local SHINY_DVS = { attack = 14, defense = 10, speed = 10, special = 10 }
local OUT = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1431"
local function tap(game, button, frames)
game.input.pressQueue[#game.input.pressQueue + 1] = button
game.input.state[button] = true
U.wait(2)
game.input.state[button] = false
U.wait(frames or 6)
end
local function openBattle(game, opts)
assert(game.world:startBattle(opts), "startBattle failed")
for _ = 1, 900 do
local top = game.stack:top()
if top and top.battle then return top end
U.wait(1)
end
error("battle screen never came up")
end
-- Stop on the frame the `.Shiny` arm is actually running: animId plus the
-- wBattleAnimParam the arm branches on (data/moves/animations.asm:414-417).
local function shinyArm(screen)
local anim = screen.anim
return anim ~= nil and anim.animId == "ANIM_SEND_OUT_MON"
and anim.param == 1
end
local function watch(game, screen, label, frames)
for _ = 1, (frames or 400) do
if shinyArm(screen) then
U.log("[driver] " .. label .. ": .Shiny arm running")
U.shot(game, ("%s/%s.png"):format(OUT, label))
return true
end
U.wait(1)
end
U.log("[driver] " .. label .. ": NO shiny arm seen")
return false
end
return function(game)
U.wait(45)
assert(game.world and game.world.map, "gold world did not boot")
-- A shiny only reads as one in colour, and the sparkle rides the same anim.
GbcPalette.setMode("gbc")
local save = game.save
save.inventory = {}
local lead = Mon.new(game.data, "CYNDAQUIL", 40, { dvs = SHINY_DVS })
local bench = Mon.new(game.data, "TOTODILE", 40, { dvs = SHINY_DVS })
assert(lead.shiny and bench.shiny, "the FORCESHINY DVs did not take")
local move = assert(game.data.moves.EMBER, "no EMBER in moves.lua")
lead.moves = { { id = "EMBER", pp = move.pp, maxPp = move.pp } }
save.party = { lead, bench }
local entry = game.world:trainerParty(36, 1) -- BUG_CATCHER member 1
assert(entry, "no BUG_CATCHER member 1 in trainers.lua")
local Trainers = require("src.world.gen2.Trainers")
entry.party = Trainers.party(game.data, entry)
for _, mon in ipairs(entry.party) do mon.shiny = true end
local screen = openBattle(game, { trainer = entry })
------------------------------------------------------------ enemy lead
-- ShowSetEnemyMonAndSendOutAnimation, out of the opening sequence.
watch(game, screen, "00-enemy-sendout", 900)
----------------------------------------------------------- player lead
-- SendOutPlayerMon behind the "Go!" line: this is the one the report is
-- about, and it never sparkled before the fix.
local sawPlayer = watch(game, screen, "01-player-sendout", 900)
for _ = 1, 600 do
if screen.phase == "menu" then break end
tap(game, "a", 2)
end
---------------------------------------------------------- player switch
-- The same routine again, this time as a voluntary mid-battle switch.
local sawSwitch = false
if screen.phase == "menu" and (screen.battle.party or {})[2] then
screen:submit({ kind = "switch", index = 2 })
sawSwitch = watch(game, screen, "02-player-switch", 900)
for _ = 1, 600 do
if screen.phase == "menu" or screen.battle.over then break end
tap(game, "a", 2)
end
end
------------------------------------------------------ enemy replacement
local sawReplace = false
if #(screen.battle.enemyParty or {}) > 1 then
for _ = 1, 600 do
if screen.battle.over then break end
if screen.phase == "menu" then
local enemy = screen.battle.enemy
if enemy then enemy.hp = 1 end
screen:chooseMenu("fight")
U.wait(2)
screen:chooseMove(1)
break
end
tap(game, "a", 2)
end
sawReplace = watch(game, screen, "03-enemy-replacement", 900)
end
U.log(("[driver] player lead-off %s, player switch %s, enemy replacement %s")
:format(tostring(sawPlayer), tostring(sawSwitch), tostring(sawReplace)))
U.log("[driver] shots in " .. OUT)
U.log("[driver] every send-out of a shiny must flash, sparkle and chime")
U.log("[driver] BEFORE its cry, not only the wild mon at battle start.")
while true do
coroutine.yield()
end
end
+72
View File
@@ -0,0 +1,72 @@
-- #1441: the Magnet Train ride drew a flat white screen.
--
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gold_bug1441_test.lua love .
-- POKEPORT_SHOT_DIR=/tmp/gold-bug1441 (default)
--
-- Nothing here is assertable: the bug IS what the screen shows. The ride
-- bakes its background out of data.gen2Field.magnetTrain, TILESET_TRAIN_
-- STATION's sheet and the TOWN palettes, and every one of those was read
-- under its Gen 1 key, so all four came back nil and GbcPalette's fallback
-- filled the panel with DMG_SHADES[1].
--
-- The run boots into the Goldenrod station, prints whether the four tables
-- are actually there, shoots the Saffron-bound ride from JUMPTABLE_INIT
-- through to the arrival, then plays the Goldenrod-bound one back at 1x with
-- no screenshots in the way -- a human watching the window sees the whole
-- animation, which is the only place this bug ever showed.
local U = require("tests.drivers.util")
local STATION = "GOLDENROD_MAGNET_TRAIN_STATION"
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1441"
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
world:setMap(STATION, 11, 8, "up")
U.wait(10)
local data = game.data or {}
local field = data.gen2Field and data.gen2Field.magnetTrain
U.log("gen2Field.magnetTrain:", field and #field.bgTiles or "MISSING")
U.log("gen2Tilesets.TILESET_TRAIN_STATION:",
data.gen2Tilesets and data.gen2Tilesets.TILESET_TRAIN_STATION
and data.gen2Tilesets.TILESET_TRAIN_STATION.image or "MISSING")
U.log("gen2Palettes:", data.gen2Palettes and "ok" or "MISSING")
U.log("gen2Sprites.SPRITE_CHRIS:",
data.gen2Sprites and data.gen2Sprites.SPRITE_CHRIS and "ok" or "MISSING")
local done = false
world:magnetTrain(true, function() done = true end)
U.wait(2)
local ride = game.stack:top()
U.log("ride background:", ride and ride.background
and (ride:background() and "baked" or "nil canvas") or "no screen")
-- SFX_TRAIN_ARRIVED lands a good while in; shoot across the whole ride so
-- the departure, the three scrolling bands and the stop are all on disk.
for index = 0, 11 do
U.shot(game, ("%s/%02d-ride.png"):format(out, index))
U.wait(20)
if done then break end
end
for _ = 1, 600 do
if done then break end
U.wait(5)
end
U.log("ride finished at frame", U.frame())
U.log("shots in " .. out)
-- The return leg, played out in full with nothing else on screen: watch the
-- window, not the PNGs. The ride reads no input, so it ends on its own.
local back = false
world:magnetTrain(false, function() back = true end)
for _ = 1, 900 do
if back then break end
U.wait(1)
end
U.wait(60)
end
+57
View File
@@ -0,0 +1,57 @@
-- Eyeball driver (#1442): the radio card's tuning knob. PokegearRadio_Init
-- spawns SPRITE_ANIM_OBJ_RADIO_TUNING_KNOB on tile $08 and AnimateTuningKnob
-- writes wRadioTuningKnob into its XOFFSET, so a red needle stands in the dial
-- box at screen x = 72 + knob and steps with every UP/DOWN. Before the fix the
-- card drew the dial art and nothing in it.
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \
-- POKEPORT_SHOTS=/tmp/radioknob \
-- POKEPORT_DRIVER=tests/drivers/gold_radio_knob_bug1442.lua \
-- perl -e 'alarm 300; exec @ARGV' \
-- python3 -c "import pty; pty.spawn(['love','.'])"
--
-- The run parks on the radio card with the knob on 08.5, so UP and DOWN move
-- the needle by hand.
local U = require("tests.drivers.util")
local SHOTS = os.getenv("POKEPORT_SHOTS") or "/tmp/radioknob"
return function(game)
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
-- Goldenrod, where the card is handed out, and the two engine flags the
-- START menu and the strip read: ENGINE_POKEGEAR and ENGINE_RADIO_CARD.
assert(world:setMap("GOLDENROD_CITY", 12, 20, "down"),
"setMap failed for GOLDENROD_CITY")
world:setEngineFlag(4, true) -- ENGINE_POKEGEAR
world:setEngineFlag(0, true) -- ENGINE_RADIO_CARD
U.wait(5)
game:openStartMenuItem("pokegear")
U.wait(5)
local gear = game.stack:top()
assert(gear and gear.cards, "the POKeGEAR did not open")
gear.mode = "card"
for index, card in ipairs(gear.cards) do
if card.id == "radio" then gear.cardIndex = index end
end
assert(gear:card().id == "radio", "the RADIO card is missing from the strip")
U.wait(5)
-- 04.5, the bottom of the dial: the needle sits at the left of the box.
U.shot(game, SHOTS .. "/01-radio-04.5.png")
U.log("knob", tostring(gear:currentStation().knob), "at 04.5")
U.tap(game, "up") U.wait(4)
U.tap(game, "up") U.wait(4)
U.shot(game, SHOTS .. "/02-radio-08.5.png")
U.log("knob", tostring(gear:currentStation().knob), "at 08.5")
U.log("compare 01 and 02: a red needle stands in the dial box and has",
"moved right; UP/DOWN now walk it by hand")
while true do
coroutine.yield()
end
end
+100
View File
@@ -0,0 +1,100 @@
-- Driver: renders the launcher's SKINS tab, exercises Use / import, and
-- shoots it. Stands in for main.lua's Importer branch so the panel can be
-- eyeballed without clicking through the real launcher.
-- SHOT_DIR=/tmp/skinstab POKEPORT_DRIVER=tests/drivers/launcher_skins_tab_shot.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local RomImporter = require("src.import.RomImporter")
local TouchSkin = require("src.core.TouchSkin")
local dir = os.getenv("SHOT_DIR") or "/tmp/skinstab"
os.execute('mkdir -p "' .. dir .. '" 2>/dev/null')
love.window.setMode(1024, 768, { resizable = true, highdpi = true })
U.wait(2)
local studioOpenedWith = nil
local imp = RomImporter.new(function() end, {
launcher = true,
onOpenSkinStudio = function(v) studioOpenedWith = v end,
})
local pending = nil
love.draw = function()
imp:draw()
if pending then
local path = pending
pending = nil
love.graphics.captureScreenshot(function(imagedata)
local f = io.open(path, "wb")
if f then f:write(imagedata:encode("png"):getString()) f:close() end
end)
end
end
local function shot(name)
pending = dir .. "/" .. name
for _ = 1, 90 do
if not pending then break end
imp:update(1 / 60)
coroutine.yield()
end
U.wait(3)
local f = io.open(dir .. "/" .. name, "rb")
U.log(f and "shot" or "FAIL shot", name)
if f then f:close() end
end
imp:_switchTab("skins")
U.wait(3)
U.log("tab:", imp.tab)
local skins = imp:_ensureSkins()
U.log("skins listed:", #skins)
for _, e in ipairs(skins) do
U.log((" %s source=%s pages=%d buttons=%d screen=%s ok=%s"):format(
e.id, e.source, e.pages, e.controls, tostring(e.screen), tostring(e.ok)))
end
U.log("active skin:", tostring(imp:_activeSkin()))
shot("skins_tab.png")
imp._gamePopup = true
U.wait(2)
shot("game_dropdown.png")
imp._gamePopup = nil
U.wait(2)
-- pick one, the way clicking its row does
imp:_useSkin("tv_crt")
U.wait(2)
U.log("after use:", tostring(imp:_activeSkin()), "notice:", imp._skinNotice.text)
shot("skins_tab_selected.png")
-- studio hook carries a real cartridge, not the "skins" tab id
imp.modScope = "red"
if imp.onOpenSkinStudio then imp.onOpenSkinStudio(imp.modScope or "red") end
U.log("studio opened with version:", tostring(studioOpenedWith))
-- import: a dropped zip on this tab is a skin, not a mod
local exported = TouchSkin.export(
assert(TouchSkin.load("assets/skins/gb_anim", "gb_anim")),
"skins/dropped_probe.zip")
U.log("staged archive:", tostring(exported))
local raw = love.filesystem.read("skins/dropped_probe.zip")
love.filesystem.remove("skins/dropped_probe.zip")
-- same shape LOVE's DroppedFile presents to readDroppedFile
local dropped = {
getFilename = function() return "/tmp/my_cool_skin.zip" end,
getSize = function() return #raw end,
open = function() return true end,
read = function() return raw end,
close = function() return true end,
}
imp:filedropped(dropped)
U.log("import notice:", imp._skinNotice.text)
U.log("skins after import:", #imp:_ensureSkins())
U.log("my_cool_skin found:", tostring(TouchSkin.find("my_cool_skin") ~= nil))
imp:_useSkin(nil)
shot("skins_tab_imported.png")
U.log("done")
love.event.quit()
while true do coroutine.yield() end
end
@@ -0,0 +1,89 @@
-- Driver: the starter Pikachu's battle entrance (#1429).
--
-- POKEPORT_DRIVER=tests/drivers/pikachu_entrance_bug1429_test.lua \
-- POKEPORT_VERSION=yellow POKEPORT_IDENTITY=bug1429 POKEPORT_TOUCH=0 \
-- SHOT_DIR=/tmp/shots love . (never under POKEPORT_SPEED: audio-timed)
--
-- SendOutMon branches on IsThisPartyMonStarterPikachu (pokeyellow
-- engine/battle/core.asm:1798-1819): the starter never gets POOF_ANIM or
-- AnimateSendingOutMon. It walks in instead --
-- StarterPikachuBattleEntranceAnimation (engine/battle/pikachu_entrance_anim.asm)
-- paints the back pic one column at a time from hlcoord 0,5, eight columns two
-- frames apart, and only then does PlayPikachuSoundClip voice it.
--
-- The run halts in the middle of the walk-in, then again on the finished pic.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local GameVersion = require("src.core.GameVersion")
local PF = require("src.world.PikachuFollower")
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
check("running as Yellow (needs POKEPORT_VERSION=yellow)",
GameVersion.isYellow())
game.save.player.name = "bryan"
local pika = Pokemon.new(game.data, "PIKACHU", 20)
BattleState.stampOT(game.save, pika)
game.save.party = { pika, Pokemon.new(game.data, "CHARMANDER", 20) }
check("the lead reads as the starter Pikachu",
PF.isStarterPikachu(game.save, pika))
U.teleport(game, "ROUTE_1", 5, 20, "down")
U.wait(20)
local ow = game.overworld
check("overworld is up to push the battle from", ow ~= nil)
local battle = BattleState.newWild(game, "PIDGEY", 5)
battle.onFinish = function() end
ow:pushBattle(battle)
local function tapUntil(cond, taps, gap)
for _ = 1, (taps or 90) do
if cond() then return true end
U.tap(game, "a")
for _ = 1, (gap or 4) do
if cond() then return true end
U.wait(1)
end
end
return cond()
end
-- Poll every frame: the slide is 16 frames long and the ball poof, if the
-- bug were back, would never set this slot at all.
local sliding = tapUntil(function()
return battle.picOff ~= nil and battle.picOff.playerMon ~= nil
end, 120, 2)
check("the send-out started the entrance slide, not the ball poof", sliding)
check("no grow-in is running alongside it", battle.growIn == nil)
if sliding then
U.log("slide x =", tostring(battle.picOff.playerMon.x))
check("mid-slide screenshot reached disk",
U.shot(game, DIR .. "/bug1429_pikachu_sliding.png"))
U.log("captured", DIR .. "/bug1429_pikachu_sliding.png")
for _ = 1, 40 do
if battle:picOffset("playerMon") == 0 then break end
U.wait(1)
end
check("the pic landed on its own column", battle:picOffset("playerMon") == 0)
check("landed screenshot reached disk",
U.shot(game, DIR .. "/bug1429_pikachu_landed.png"))
U.log("captured", DIR .. "/bug1429_pikachu_landed.png")
end
U.log("Pikachu should have walked in from the LEFT edge of the screen,")
U.log("column by column, with no ball, no poof and no grow-out-of-the-ball,")
U.log("and voiced its PCM clip only once it was fully drawn (#1429).")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,114 @@
-- Driver: where the "Will PLAYER change POKéMON?" YES/NO box sits (#1398).
--
-- POKEPORT_DRIVER=tests/drivers/shift_prompt_box_bug1398_test.lua \
-- POKEPORT_IDENTITY=bug1398 POKEPORT_TOUCH=0 POKEPORT_VERSION=red \
-- SHOT_DIR=/tmp/shots love .
--
-- EnemySendOutFirstMon (engine/battle/core.asm:1378-1384) does NOT go through
-- InitYesNoTextBoxParameters for this one prompt: it inlines its own
-- TWO_OPTION_MENU at hlcoord 0,7, i.e. hard against the LEFT edge, while every
-- other YES/NO in the game sits at hlcoord 14,7 on the right. The port only
-- had the shared right-hand box, so the SHIFT offer came up on the wrong side.
--
-- The run stops with the prompt on screen and the controls in a human's hands.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local Theme = require("src.ui.Theme")
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- roster 1 is RATTATA 11 / EKANS 11: two mons, so the KO of the first one
-- reaches EnemySendOutFirstMon with a reserve to announce.
local OPP, ROSTER = "OPP_YOUNGSTER", 1
check("trainer class " .. OPP .. " is in the data",
game.data.trainers ~= nil and game.data.trainers[OPP] ~= nil)
check("Theme carries the left-edge box",
Theme.trainerSwitchBox ~= nil and Theme.trainerSwitchBox.tx == 0
and Theme.trainerSwitchBox.ty == 7)
-- SHIFT is what makes the prompt appear at all (the BIT_BATTLE_SHIFT test at
-- core.asm:1375-1377 skips it under SET).
game.save.options = game.save.options or {}
game.save.options.battleStyle = "shift"
game.save.player.name = "bryan"
game.save.party = {
Pokemon.new(game.data, "MEWTWO", 70),
Pokemon.new(game.data, "CHARIZARD", 50),
}
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.wait(20)
local ow = game.overworld
check("overworld is up to push the battle from", ow ~= nil)
local ok, battle = pcall(BattleState.newTrainer, game, OPP, ROSTER)
check("trainer battle constructed", ok and battle ~= nil)
if not ok then
U.log("could not start", OPP, "->", tostring(battle))
while true do coroutine.yield() end
end
check("the foe has a reserve to send out", #battle.enemyParty >= 2)
battle.onFinish = function() end
ow:pushBattle(battle)
local function tapUntil(cond, taps, gap)
for _ = 1, (taps or 60) do
if cond() then return true end
U.tap(game, "a")
for _ = 1, (gap or 6) do
if cond() then return true end
U.wait(1)
end
end
return cond()
end
if not check("reached the FIGHT/PKMN/ITEM/RUN menu",
tapUntil(function() return battle.phase == "menu" end, 200)) then
U.log("phase is", tostring(battle.phase))
end
-- FIGHT, first move: L70 MEWTWO one-shots the L11 lead.
U.tap(game, "a")
U.wait(10)
U.tap(game, "a")
U.wait(10)
-- Stop the moment the YES/NO goes up over the still-visible prompt page.
local function choiceBox()
local top = game.stack:top()
return (top and top.tx and top.labels and top.labels[1] == "YES") and top
or nil
end
local reached = tapUntil(function() return choiceBox() ~= nil end, 200, 4)
if not check("the SHIFT prompt opened its YES/NO box", reached) then
U.log("phase", tostring(battle.phase),
"enemy hp", tostring(battle.enemy and battle.enemy.mon.hp),
"top", tostring(game.stack:top()))
end
local box = choiceBox()
if box then
check("the box is at hlcoord 0,7 (core.asm:1378-1384)",
box.tx == 0 and box.ty == 7)
U.log("box tx=" .. tostring(box.tx) .. " ty=" .. tostring(box.ty))
check("prompt screenshot reached disk",
U.shot(game, DIR .. "/bug1398_shift_prompt.png"))
U.log("captured", DIR .. "/bug1398_shift_prompt.png")
end
U.log("The foe's lead is down and the SHIFT offer is on screen.")
U.log("YES/NO must sit against the LEFT edge, over the text box's left half,")
U.log("not on the right where every other YES/NO in the game lives (#1398).")
while true do
coroutine.yield()
end
end
+116
View File
@@ -0,0 +1,116 @@
-- Driver: authors a CRT bezel skin from nothing using only Skin Studio
-- actions -- New, drop the art on the window, Detect screen, Save -- then
-- loads the saved folder back and renders the game through it. Answers
-- "could the studio have made tv_crt?" by doing it.
-- SHOT_DIR=/tmp/author POKEPORT_DRIVER=tests/drivers/skin_studio_author_tv.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Studio = require("src.ui.SkinStudio")
local TouchSkin = require("src.core.TouchSkin")
local dir = os.getenv("SHOT_DIR") or "/tmp/author"
os.execute('mkdir -p "' .. dir .. '" 2>/dev/null')
love.window.setMode(1280, 720, { resizable = true, highdpi = true })
U.wait(2)
local baseDraw = love.draw
local pending = nil
love.draw = function()
Studio.draw()
if pending then
local path = pending
pending = nil
love.graphics.captureScreenshot(function(imagedata)
local f = io.open(path, "wb")
if f then f:write(imagedata:encode("png"):getString()) f:close() end
end)
end
end
local function shot(name)
pending = dir .. "/" .. name
for _ = 1, 90 do
if not pending then break end
coroutine.yield()
end
U.wait(3)
end
-- a stand-in for LOVE's DroppedFile, same three calls Studio.filedropped uses
local function droppedFile(realPath)
local handle
return {
getFilename = function() return realPath end,
open = function() handle = assert(io.open(realPath, "rb")) end,
read = function() return handle:read("*a") end,
close = function() if handle then handle:close() handle = nil end end,
}
end
Studio.load({ onClose = function() end })
U.wait(3)
-- 1. New skin, named
Studio.skin = TouchSkin.newSkin("my_tv")
Studio.skinIdField = "my_tv"
Studio.pageIndex, Studio.selected = 1, nil
Studio.images = {}
TouchSkin.setActive(Studio.skin)
U.log("1. new skin:", Studio.skin.id, "controls:", #Studio.page().controls)
-- 2. Desktop canvas preset
for i, c in ipairs(Studio.CANVASES) do
if c.id == "desktop_1080" then Studio.setCanvas(i) end
end
U.log("2. canvas:", Studio.canvas().label, Studio.canvas().w .. "x" .. Studio.canvas().h)
-- 3. Drop the bezel art on the window
Studio.filedropped(droppedFile("assets/skins/tv_crt/img/tv-integer.png"))
U.log("3. drop:", Studio.status)
U.log(" bezel:", tostring(Studio.page().imagePath), "root:", Studio.skin.root)
-- 4. Detect the screen hole from the art's alpha
Studio.detectViewport()
local v = Studio.page().viewport
U.log("4. detect:", Studio.status)
U.log((" viewport %.4f %.4f %.4f %.4f"):format(v.x, v.y, v.w, v.h))
shot("author_studio.png")
-- 5. Save
Studio.save()
U.log("5. save:", Studio.status)
-- what landed on disk
local root = "skins/my_tv"
for _, name in ipairs(love.filesystem.getDirectoryItems(root)) do
local info = love.filesystem.getInfo(root .. "/" .. name)
U.log(" file:", name, info and info.type)
end
local src = love.filesystem.read(root .. "/skin.lua")
U.log(" skin.lua bytes:", src and #src)
U.log(" skin.lua:", (src or ""):gsub("%s+", " "):sub(1, 260))
-- 6. Load the saved folder back as a normal skin and play through it
Studio.unload()
local reloaded, err = TouchSkin.load(root, "my_tv")
U.log("6. reload:", reloaded and "ok" or tostring(err),
"format:", reloaded and reloaded.format,
"controls:", reloaded and #reloaded.pages[1].controls)
game.save.options.touchControls = { enabled = true, skin = "my_tv" }
game.save.options.tilt = 0
game.save.options.zoom = 0
game.save.options.pipelines = {}
game:applyOptions()
local TouchControls = require("src.core.TouchControls")
U.log(" active:", tostring(TouchControls.skinId), "err:", tostring(TouchControls.skinError))
U.log(" decorativeOnly:", TouchSkin.decorativeOnly(), "drawable:", TouchSkin.drawable())
love.draw = baseDraw
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.wait(12)
U.shot(game, dir .. "/author_result.png")
U.log("done")
love.event.quit()
while true do coroutine.yield() end
end
+57
View File
@@ -0,0 +1,57 @@
-- Driver: presses Play in the Skin Studio the way the button does, then keeps
-- drawing and pumping update so the deferred handoff runs. Regression cover
-- for the crash where Play unloaded the studio inside its own draw pass.
-- POKEPORT_DRIVER=tests/drivers/skin_studio_play_test.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Studio = require("src.ui.SkinStudio")
love.window.setMode(1280, 720, { resizable = true, highdpi = true })
U.wait(2)
local booted, bootVersion = false, nil
Studio.load({
version = "red",
onClose = function() end,
onPlay = function(v)
-- what main.lua's handler does: unload, then boot
Studio.unload()
booted, bootVersion = true, v
end,
})
U.wait(3)
U.log("loaded skin:", Studio.skin and Studio.skin.id)
Studio.skinIdField = "play_probe"
-- press Play exactly as the inspector button does
Studio.play()
U.log("after play(): pendingPlay =", tostring(Studio.pendingPlay),
"skin alive =", tostring(Studio.skin ~= nil), "booted =", tostring(booted))
U.log("status:", tostring(Studio.status))
-- the frame that queued the handoff must still draw
local okDraw, errDraw = pcall(Studio.draw)
U.log("draw on the click frame:", okDraw, errDraw or "")
-- now the deferred handoff
local okUp, errUp = pcall(Studio.update, 1 / 60)
U.log("update handoff:", okUp, errUp or "")
U.log("booted =", tostring(booted), "version =", tostring(bootVersion),
"skin =", tostring(Studio.skin))
-- and a draw after teardown, which is what actually crashed before
local okAfter, errAfter = pcall(Studio.draw)
U.log("draw after teardown:", okAfter, errAfter or "")
local okUp2 = pcall(Studio.update, 1 / 60)
U.log("second update:", okUp2, "booted still once =", tostring(booted))
if okDraw and okUp and okAfter and booted then
U.log("RESULT pass")
else
U.log("RESULT FAIL")
end
love.event.quit()
while true do coroutine.yield() end
end
+107
View File
@@ -0,0 +1,107 @@
-- Driver: shoots the desktop Skin Studio (#1386) across its canvas presets
-- and in Test mode. The studio is a top-level love.draw branch in main.lua,
-- so this stands in for that branch and captures the frames itself.
-- SHOT_DIR=/tmp/studio POKEPORT_DRIVER=tests/drivers/skin_studio_shot.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Studio = require("src.ui.SkinStudio")
local TouchControls = require("src.core.TouchControls")
local dir = os.getenv("SHOT_DIR") or "/tmp/studio"
os.execute('mkdir -p "' .. dir .. '" 2>/dev/null')
love.window.setMode(1440, 900, { resizable = true, highdpi = true })
U.wait(2)
local pending = nil
love.draw = function()
Studio.draw()
if pending then
local path = pending
pending = nil
love.graphics.captureScreenshot(function(imagedata)
local f = io.open(path, "wb")
if f then f:write(imagedata:encode("png"):getString()) f:close() end
end)
end
end
local function shot(name)
pending = dir .. "/" .. name
for _ = 1, 90 do
if not pending then break end
coroutine.yield()
end
U.wait(3)
local f = io.open(dir .. "/" .. name, "rb")
if f then f:close() U.log("shot", name) else U.log("FAIL shot", name) end
end
Studio.load({ skinId = "gb_anim", onClose = function() end })
U.wait(5)
U.log("skin:", Studio.skin and Studio.skin.id, "pages:", #(Studio.skin.pages or {}))
U.log("controls:", #Studio.page().controls, "images:", #(Studio.images or {}))
shot("studio_open.png")
Studio.selected = 9
U.wait(3)
local ctl = Studio.selectedControl()
U.log("selected:", ctl and ctl.spec, "x", ctl and ctl.x, "y", ctl and ctl.y)
shot("studio_selected.png")
-- drag the selected control and confirm the model moved
local r = Studio.lastCanvas
if r and ctl then
local before = ctl.x
Studio.beginCanvasDrag(r.x + ctl.x * r.w, r.y + ctl.y * r.h, r)
Studio.updateDrag(r.x + ctl.x * r.w - 60, r.y + ctl.y * r.h, r)
Studio.drag = nil
U.log("drag moved x:", before, "->", ctl.x)
end
U.wait(3)
shot("studio_dragged.png")
-- numeric entry: type an exact pixel X
if ctl then
Studio.commitField("numX", "100")
U.log("after numX=100 px, x fraction:", ctl.x, "rangeX:", ctl.rangeX)
end
Studio.testing = true
TouchControls:setPreview(false)
U.wait(3)
if r then
local page = Studio.page()
local TouchSkin = require("src.core.TouchSkin")
TouchSkin.setSurface(r.x, r.y, r.w, r.h)
local cx, cy = TouchSkin.controlGeometry(page, page.controls[13], r.w, r.h, r.x, r.y)
Studio.mousepressed(cx, cy, 1)
TouchSkin.setSurface(nil)
local held = {}
for btn in pairs(TouchControls.held or {}) do held[#held + 1] = btn end
U.log("test press held:", table.concat(held, ","))
end
U.wait(3)
shot("studio_test.png")
Studio.mousereleased(0, 0, 1)
Studio.testing = false
TouchControls:setPreview(true)
-- Super Game Boy border preset: viewport locks to the 160x144 window
for i, c in ipairs(Studio.CANVASES) do
if c.id == "sgb_border" then Studio.setCanvas(i) end
end
U.wait(3)
local vp = Studio.page().viewport
U.log("sgb viewport:", vp.x, vp.y, vp.w, vp.h)
shot("studio_sgb.png")
for i, c in ipairs(Studio.CANVASES) do
if c.id == "ultrawide" then Studio.setCanvas(i) end
end
U.wait(3)
shot("studio_ultrawide.png")
U.log("done")
love.event.quit()
while true do coroutine.yield() end
end
+73
View File
@@ -0,0 +1,73 @@
-- Driver: nobody is caught mid-stride while a text box is up (#1435).
-- UpdatePlayerSprite and UpdateNPCSprite both jump to their standing frame
-- while BIT_FONT_LOADED is set (engine/overworld/movement.asm:57, :139), so a
-- box that opens on a walk frame freezes a standing sprite, never a leg out.
--
-- POKEPORT_DRIVER=tests/drivers/talk_pose_bug1435_test.lua \
-- POKEPORT_IDENTITY=bug1435 POKEPORT_TOUCH=0 POKEPORT_VERSION=red \
-- SHOT_DIR=/tmp/shots love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local TextBox = require("src.render.TextBox")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- Mom stands at (5,4) in Red's house (pokered
-- data/maps/objects/RedsHouse1F.asm); park the player beside her and push
-- into her so the walk-in-place cycle is running when the box opens.
U.teleport(game, "REDS_HOUSE_1F", 6, 4, "down")
U.wait(20)
local ow = game.overworld
local p = ow.player
local walking = false
for _ = 1, 90 do
table.insert(game.input.pressQueue, "left")
game.input.state.left = true
coroutine.yield()
if p:walkPhase() == 1 then walking = true break end
end
game.input.state.left = false
check("bumping into MOM shows the walk frame", walking)
U.shot(game, DIR .. "/bug1435_1_midstride.png")
U.tap(game, "a")
U.wait(4)
check("talking to MOM opened a box over the overworld",
game.stack:top() ~= ow)
check("the walk clock is still mid-cycle underneath",
(p.bumpFrames or 0) > 0 or p.moving)
check("but the player is drawn standing", p:walkPhase() == 0)
U.shot(game, DIR .. "/bug1435_2_talking.png")
-- The same gate for an NPC caught mid-step: Pallet Town's wanderers walk
-- on their own, so wait for one and open a box while it is between cells.
U.teleport(game, "PALLET_TOWN", 8, 8, "down")
U.wait(30)
ow = game.overworld
local mover
for _ = 1, 900 do
for _, npc in ipairs(ow.npcs) do
if npc.moving and npc:walkPhase() == 1 then mover = npc break end
end
if mover then break end
coroutine.yield()
end
if check("caught a Pallet Town NPC mid-step", mover ~= nil) then
game.stack:push(TextBox.new(game, "TESTING THE POSE."))
U.wait(4)
check("the NPC is still mid-step underneath", mover.moving)
check("but it is drawn standing too", mover:walkPhase() == 0)
U.shot(game, DIR .. "/bug1435_3_npc.png")
end
U.log("Look at bug1435_2_talking.png and bug1435_3_npc.png: with the box")
U.log("up, both sprites stand square on their feet. A leg out, or the")
U.log("split-stride frame held for the whole conversation, is the bug.")
while true do coroutine.yield() end
end
+176
View File
@@ -0,0 +1,176 @@
-- Manual check for #1453: the SGB zone scissors must stay contiguous in
-- framebuffer pixels on a fractional-DPI surface (Android), where LOVE 11
-- truncates the scissor to whole units before scaling it. Zones are pokered
-- data/sgb/sgb_packets.asm BlkPacket_Titlescreen (rows 0-7 / 8-9 / 10-17).
-- POKEPORT_DRIVER=tests/drivers/title_seam_bug1453_test.lua POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
-- PROBE_DPI picks the density to emulate (default 2.625, the reporter's).
-- Do not set POKEPORT_SPEED: fast-forward desynchronizes the title music.
return function(game)
local U = dofile("tests/drivers/util.lua")
local PaletteFX = require("src.render.PaletteFX")
local Renderer = require("src.render.Renderer")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local DPI = tonumber(os.getenv("PROBE_DPI") or "") or 2.625
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- past the copyright splash / attract movie (engine/movie/splash.asm)
local title
for _ = 1, 120 do
local top = game.stack:top()
if top and top.screenId == "TitleState" and top.sgbPalettes then
title = top
break
end
U.tap(game, "start")
U.wait(9)
end
check("the title screen is on top", title ~= nil)
if not (title and title.sgbPalettes) then
U.log("No title state to look at; nothing below can run.")
while true do coroutine.yield() end
end
local opts = game.save.options
local mode = opts and opts.colors or PaletteFX.mode
if PaletteFX.mode ~= "gbc" then
U.log("COLORS is", tostring(mode) .. "; switching the view to SGB for the check")
PaletteFX.setMode("gbc")
U.wait(20)
end
local zones = PaletteFX.ensureZones(title:sgbPalettes(game))
check("the title builds three SGB zones", zones ~= nil and #zones == 3)
-- A canvas carries its own dpiscale, so the emulated Android surface goes
-- through the same LOVE scissor path the phone does, on this desktop.
local probe = love.graphics.newCanvas(math.floor(1920 / DPI),
math.floor(1080 / DPI),
{ dpiscale = DPI })
local PW, PH = probe:getPixelWidth(), probe:getPixelHeight()
local Sp = math.max(1, math.floor(math.min(PW / 160, PH / 144)))
local Ux, Uy = Sp / DPI, Sp / DPI
local uox = math.floor((PW - 160 * Sp) / 2) / DPI
local uoy = math.floor((PH - 144 * Sp) / 2) / DPI
local uvpw, uvph = 160 * Ux, 144 * Uy
U.log(("emulating %dx%d px at dpi %.4f, fit scale %d"):format(PW, PH, DPI, Sp))
-- what the pre-fix Renderer handed LOVE: fractional units with a half
-- framebuffer pixel of bias, which LOVE 11 truncates away
local function scissorOld(x, y, w, h)
local x2, y2 = math.min(x + w, uox + uvpw), math.min(y + h, uoy + uvph)
x, y = math.max(x, uox), math.max(y, uoy)
if x2 <= x or y2 <= y then return false end
local px1, py1 = math.floor(x * DPI), math.floor(y * DPI)
local px2, py2 = math.ceil(x2 * DPI), math.ceil(y2 * DPI)
love.graphics.setScissor((px1 + 0.5) / DPI, (py1 + 0.5) / DPI,
(px2 - px1 + 0.5) / DPI, (py2 - py1 + 0.5) / DPI)
return true
end
local function renderProbe(old)
love.graphics.setCanvas(probe)
love.graphics.clear(0, 0, 0, 1)
love.graphics.setColor(1, 1, 1, 1)
if old then
local shader = PaletteFX.shader()
love.graphics.setShader(shader)
for _, z in ipairs(zones) do
PaletteFX.sendColors(shader, z.colors)
if scissorOld(uox + z.x * Ux, uoy + z.y * Uy, z.w * Ux, z.h * Uy) then
love.graphics.draw(Renderer.canvas, uox, uoy, 0, Ux, Uy)
end
end
love.graphics.setScissor()
love.graphics.setShader()
else
Renderer:blitCanvas(Renderer.canvas, Ux, Uy, zones, Ux, Uy,
uox, uoy, uox, uoy, uvpw, uvph, DPI, DPI)
end
love.graphics.setCanvas()
return probe:newImageData()
end
local top = math.floor(uoy * DPI)
local bottom = math.floor((uoy + uvph) * DPI)
local col = math.floor(uox * DPI) + 2
local function blackRows(data)
local rows = {}
for py = top, math.min(bottom - 1, data:getHeight() - 1) do
local r, g, b = data:getPixel(col, py)
if r < 0.02 and g < 0.02 and b < 0.02 then rows[#rows + 1] = py end
end
return rows
end
local dataNew = renderProbe(false)
local dataOld = renderProbe(true)
local rowsNew, rowsOld = blackRows(dataNew), blackRows(dataOld)
U.log(("picture rows %d-%d, sampling the background column at x=%d")
:format(top, bottom - 1, col))
U.log("pre-fix math left", #rowsOld, "black rows:",
#rowsOld > 0 and table.concat(rowsOld, ",") or "none")
check("no letterbox row shows through the zone boundaries", #rowsNew == 0)
check("the emulated surface reproduces the seam without the fix", #rowsOld > 0)
local ZOOM, CW, CH = 4, 220, 28
local cy = math.max(top, math.floor((uoy + 64 * Uy) * DPI) - math.floor(CH / 2))
local cx = math.floor(uox * DPI)
local function zoomOf(data)
local out = love.image.newImageData(CW * ZOOM, CH * ZOOM)
out:mapPixel(function(x, y)
return data:getPixel(cx + math.floor(x / ZOOM), cy + math.floor(y / ZOOM))
end)
return out
end
local function writePng(imageData, path)
local dir = path:match("^(.*)[/\\][^/\\]+$")
if dir and dir ~= "" then os.execute('mkdir -p "' .. dir .. '" 2>/dev/null') end
local f = io.open(path, "wb")
if not f then return false end
f:write(imageData:encode("png"):getString())
f:close()
return true
end
local zoomOld, zoomNew = zoomOf(dataOld), zoomOf(dataNew)
check("wrote " .. SHOT_DIR .. "/bug1453_before.png",
writePng(zoomOld, SHOT_DIR .. "/bug1453_before.png"))
check("wrote " .. SHOT_DIR .. "/bug1453_after.png",
writePng(zoomNew, SHOT_DIR .. "/bug1453_after.png"))
local imgOld = love.graphics.newImage(zoomOld)
local imgNew = love.graphics.newImage(zoomNew)
local baseDraw = love.draw
love.draw = function()
baseDraw()
local w, h = love.graphics.getDimensions()
local s = math.min((w - 24) / imgOld:getWidth(),
(h * 0.5 - 48) / (imgOld:getHeight() * 2))
local bw = imgOld:getWidth() * s + 16
local bh = imgOld:getHeight() * 2 * s + 56
local bx, by = (w - bw) / 2, h - bh - 8
love.graphics.setColor(0, 0, 0, 0.75)
love.graphics.rectangle("fill", bx, by, bw, bh)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.print("BEFORE (dpi " .. DPI .. ")", bx + 8, by + 4)
love.graphics.draw(imgOld, bx + 8, by + 20, 0, s, s)
love.graphics.print("AFTER", bx + 8, by + 24 + imgOld:getHeight() * s)
love.graphics.draw(imgNew, bx + 8, by + 40 + imgOld:getHeight() * s, 0, s, s)
end
PaletteFX.setMode(mode)
U.wait(10)
U.shot(game, SHOT_DIR .. "/bug1453_title.png")
U.log("captured", SHOT_DIR .. "/bug1453_title.png")
U.log("The title is live and the pad is yours; the strip along the bottom is")
U.log("the row-64 zone boundary of an emulated " .. DPI .. "x Android surface,")
U.log("magnified " .. ZOOM .. "x: BEFORE carries the black hairline across the")
U.log("picture, AFTER is unbroken off-white. PROBE_DPI=2.75 tries another one.")
while true do
coroutine.yield()
end
end
+59
View File
@@ -0,0 +1,59 @@
-- Driver: shoots the launcher's Touch Controls editor with the built-in pad
-- and with the bundled skin picked, so the Skin row (#1386) can be eyeballed.
-- The editor is a top-level love.draw branch in main.lua, not a stack state,
-- so this stands in for that branch and captures the frame itself.
-- SHOT_DIR=/tmp/skin POKEPORT_DRIVER=tests/drivers/touch_skin_editor_shot.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Editor = require("src.ui.TouchControlsEditor")
local TouchControls = require("src.core.TouchControls")
local dir = os.getenv("SHOT_DIR") or "/tmp/skin"
os.execute('mkdir -p "' .. dir .. '" 2>/dev/null')
love.window.setMode(432, 768, { resizable = true, highdpi = true })
U.wait(2)
local pending = nil
love.draw = function()
Editor.draw()
if pending then
local path = pending
pending = nil
love.graphics.captureScreenshot(function(imagedata)
local f = io.open(path, "wb")
if f then f:write(imagedata:encode("png"):getString()) f:close() end
end)
end
end
local function shot(name)
pending = dir .. "/" .. name
for _ = 1, 60 do
if not pending then break end
coroutine.yield()
end
U.wait(3)
local f = io.open(dir .. "/" .. name, "rb")
if f then f:close() U.log("shot", name) else U.log("FAIL shot", name) end
end
Editor.load({ onClose = function() end })
U.wait(5)
U.log("skins:", #Editor.skins, "label:", Editor.skinLabel())
shot("editor_pad.png")
Editor.cycleSkin(1)
U.wait(5)
U.log("picked:", tostring(TouchControls.skinId), Editor.skinLabel())
shot("editor_skin.png")
Editor.exportSkin()
U.wait(5)
U.log("export:", tostring(Editor.exportMsg))
U.log("skins after export:", #Editor.skins)
shot("editor_export.png")
U.log("done")
love.event.quit()
while true do coroutine.yield() end
end
+71
View File
@@ -0,0 +1,71 @@
-- Driver: shoots the bundled RetroArch-format touch skin (assets/skins/gb_anim)
-- over a live overworld in a portrait window, idle and with A/DOWN held.
-- SHOT_DIR=/tmp/skin POKEPORT_TOUCH=1 POKEPORT_DRIVER=tests/drivers/touch_skin_shot.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local TouchControls = require("src.core.TouchControls")
local TouchSkin = require("src.core.TouchSkin")
local Pokemon = require("src.pokemon.Pokemon")
local dir = os.getenv("SHOT_DIR") or "/tmp/skin"
local skinId = os.getenv("SKIN") or "gb_anim"
love.window.setMode(432, 768, { resizable = true, highdpi = true })
U.wait(2)
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
game.save.player.name = "bryan"
if os.getenv("SKIN") == "none" then skinId = nil end
game.save.options.touchControls = { enabled = true, skin = skinId }
game.save.options.tilt = 0
game.save.options.zoom = 0
game.save.options.pipelines = {}
game:applyOptions()
U.log("skins on disk:")
for _, entry in ipairs(TouchSkin.list()) do
U.log(" ", entry.id, entry.source, entry.root)
end
U.log("active:", tostring(TouchControls.skinId), "err:", tostring(TouchControls.skinError))
local page = TouchSkin.page()
U.log("page:", page and page.name, "controls:", page and #page.controls)
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.wait(10)
local R = game.renderer
local pw, ph = love.graphics.getPixelDimensions()
U.log("pixel dims:", pw, ph)
U.log("skin viewport:", TouchSkin.viewport(pw, ph))
U.log("hasViewport:", TouchSkin.hasViewport(), "fitScale:", R:fitScale())
U.log("worldViewSize:", R:worldViewSize())
U.log("worldOverride:", tostring(R.worldOverride), "worldActive:", tostring(R.worldActive))
U.shot(game, dir .. "/skin_idle.png")
local ww, wh = love.graphics.getDimensions()
local function at(nx, ny) return nx * ww, ny * wh end
TouchControls:touchpressed("t1", at(0.87407, 0.72417))
TouchControls:touchpressed("t2", at(0.24074, 0.79771))
U.wait(4)
U.log("held:", (function()
local out = {}
for btn in pairs(TouchControls.held or {}) do out[#out + 1] = btn end
table.sort(out)
return table.concat(out, ",")
end)())
U.shot(game, dir .. "/skin_pressed.png")
TouchControls:touchreleased("t1", at(0.87407, 0.72417))
TouchControls:touchreleased("t2", at(0.24074, 0.79771))
U.wait(4)
TouchControls:touchpressed("t3", at(0.95, 0.528))
TouchControls:touchreleased("t3", at(0.95, 0.528))
U.wait(6)
U.log("page after overlay_next:", TouchSkin.page() and TouchSkin.page().name)
U.shot(game, dir .. "/skin_page2.png")
U.log("done")
love.event.quit()
while true do coroutine.yield() end
end
@@ -0,0 +1,72 @@
-- Driver: the TOWN MAP player marker wears the OBJ palette (#1437).
-- LoadPlayerSpriteGraphics hands the town map the same walking sheet the
-- overworld draws (engine/items/town_map.asm:342), so the marker has to go
-- through the OBJ ramp and the shade-0 keying every other sprite gets --
-- green on Red, pink on Blue, never a raw white block on the BG ramp.
--
-- POKEPORT_DRIVER=tests/drivers/town_map_marker_bug1437_test.lua \
-- POKEPORT_IDENTITY=bug1437 POKEPORT_TOUCH=0 POKEPORT_VERSION=red \
-- SHOT_DIR=/tmp/shots love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Screens = require("src.ui.Screens")
local PaletteFX = require("src.render.PaletteFX")
local SpriteRenderer = require("src.render.SpriteRenderer")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
game.save.player.name = "bryan"
game.save.visited = {
PALLET_TOWN = true, VIRIDIAN_CITY = true, PEWTER_CITY = true,
CERULEAN_CITY = true, CELADON_CITY = true,
}
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.wait(10)
local function openMap(mode, tag)
game.save.options = game.save.options or {}
game.save.options.colors = mode
PaletteFX.setMode(mode)
U.wait(4)
Screens.push(game, "TownMap")
U.wait(20)
local top = game.stack:top()
check(tag .. ": the TOWN MAP is up", top and top.playerQuad ~= nil)
-- the marker must be a baked OBJ image, not a fresh newImage of the sheet
local sprites = game.data.sprites or {}
local red = sprites.SPRITE_RED
local colors, group
if PaletteFX.usesSpriteObp() then
colors, group = PaletteFX.ogObj()
else
colors, group = PaletteFX.dmgObj()
end
local want = red and SpriteRenderer.obpImage(red.image, colors, group)
check(tag .. ": the marker is the OBP-baked sheet",
top and want ~= nil and top.playerSheet == want)
-- hold the shot on a frame the blink is showing the marker
for _ = 1, 40 do
if top.blink < 16 then break end
coroutine.yield()
end
U.shot(game, DIR .. "/bug1437_" .. tag .. ".png")
U.tap(game, "b")
U.wait(10)
end
openMap("ogred", "ogred")
openMap("gbc", "gbc")
openMap("og", "og")
U.log("Open the shots: in OG RED the marker over PALLET TOWN is the green")
U.log("boot-ROM trainer, in the other modes it is coloured by the map zone")
U.log("like any overworld sprite. A white box, or a red/black marker on")
U.log("the BG ramp, is the bug. The pad is yours -- the TOWN MAP is one")
U.log("B away and the ITEM bag reopens it.")
while true do coroutine.yield() end
end
+53
View File
@@ -0,0 +1,53 @@
-- Driver: shoots the bundled desktop CRT bezel (assets/skins/tv_crt) over a
-- live overworld in a 16:9 window, and checks the alpha-based screen detector
-- against the viewport the .cfg declares.
-- SHOT_DIR=/tmp/tv POKEPORT_DRIVER=tests/drivers/tv_skin_shot.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local TouchControls = require("src.core.TouchControls")
local TouchSkin = require("src.core.TouchSkin")
local Pokemon = require("src.pokemon.Pokemon")
local dir = os.getenv("SHOT_DIR") or "/tmp/tv"
local skinId = os.getenv("SKIN") or "tv_crt"
love.window.setMode(1280, 720, { resizable = true, highdpi = true })
U.wait(2)
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
game.save.player.name = "bryan"
game.save.options.touchControls = { enabled = true, skin = skinId }
game.save.options.tilt = 0
game.save.options.zoom = 0
game.save.options.pipelines = {}
game:applyOptions()
U.log("active:", tostring(TouchControls.skinId), "err:", tostring(TouchControls.skinError))
local page = TouchSkin.page()
U.log("page:", page and page.name, "controls:", page and #page.controls)
local v = page and page.viewport
U.log("declared viewport:", v and v.x, v and v.y, v and v.w, v and v.h)
local detected = TouchSkin.detectViewport(TouchSkin.active.root, page.imagePath)
if detected then
U.log(("detected hole: %.4f %.4f %.4f %.4f"):format(
detected.x, detected.y, detected.w, detected.h))
U.log(("delta vs declared: %.4f %.4f %.4f %.4f"):format(
detected.x - v.x, detected.y - v.y, detected.w - v.w, detected.h - v.h))
else
U.log("FAIL no hole detected")
end
local pw, ph = love.graphics.getPixelDimensions()
U.log("pixel dims:", pw, ph)
U.log("engine viewport px:", TouchSkin.viewport(pw, ph))
U.log("fitScale:", game.renderer:fitScale())
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.wait(12)
U.shot(game, dir .. "/tv_overworld.png")
U.log("done")
love.event.quit()
while true do coroutine.yield() end
end
@@ -46,4 +46,30 @@ do
T.eq(tradedWithId.otId, 777, "a recorded foreign OT id is never overwritten")
end
-- #1461: saves that passed through 0.1.82-0.1.9x already have the player's
-- own id written onto traded mons, so stamping correctly from now on does
-- not help them. The repair runs on every load, not behind a format gate.
do
local SaveData = require("src.core.SaveData")
local save = newSave()
local poisoned = { traded = true, otId = 12345 }
local foreign = { traded = true, otId = 777 }
local caught = { otId = 12345 }
save.party = { poisoned, foreign, caught }
save.boxes = { { { traded = true, otId = 12345 } } }
T.eq(SaveData.repairTradedOtIds(save), 2, "both poisoned mons are repaired")
T.eq(poisoned.otId, nil, "a traded mon carrying the player id is cleared")
T.eq(save.boxes[1][1].otId, nil, "boxed mons are repaired too")
T.eq(foreign.otId, 777, "a real foreign OT id is left alone")
T.eq(caught.otId, 12345, "a caught mon keeps the player id")
T.eq(caught.traded, nil, "and is never marked traded")
T.eq(SaveData.repairTradedOtIds(save), 0, "the repair is idempotent")
-- and after the repair the stamp loop must not re-adopt it
BattleState.stampOT(save, poisoned)
T.eq(poisoned.otId, nil, "the stamp loop does not undo the repair")
end
T.finish("exp traded ot survives reload bug 1265")

Some files were not shown because too many files have changed in this diff Show More