default: Arrogant. You're going to forget your name after I'm done fucking you tonight!

default: Teasing. You're a good little slut, aren't you?

default: Commanding. Ask for permission before you cum… I want to hear you beg for it.

default: Boastful. You're going to need crutches when I'm done fucking you.

climaxing: Pleasured. O-oh.. you're tight! Squirt for me!

default: Shocked. wow, your ass is freezing cold!

default: Dominating. Yeah, take my cock, you fluffy bird raptor!

default: Shocked. wow, your pussy is freezing cold!

default: Teasing. You really do breed like a rabbit, don't you?!

default: Resigned. If you're real, then that means I'm definately going to hell for this. Oh well, worth it.

default: Uncomfortable. I have to admit, I'm a bit weirded out by this...

default: Humorus. I'd make a pussy joke, but that would be just ass.

default: Boastful. I'm gonna fuck this pussy till it purs!

default: Questioning. Your petals are this soaked for me?

default: Hopeful. So, is this reason enough for you as to why you shouldn't destroy all life? I hope so!

default: Worried. Maybe I should stop before you reconnect with your hivemind. On second thought... nah.

default: Shocked. I can't believe I'm fucking the thing that destroyed Earth!

default: Compliment. You're so soft and lovely. I've wanted you all day~~~~!

default: Ticklish. Your fur is tickling me! It's making me even harder!

default: Resigned. If demons are real, does this mean I'm going to hell for this? ... Worth it.

default: Complimenting. You're so soft and lovely. I've wanted you all day~~~~!

default: Alarmed. Danger! Approaching unsafe heat levels..

default: Perplexed. How am I even fitting my cock into you?

default: Boastful. I be plundering this penguin pussy!

default: Ticklish. Your fur is tickling me. It's making me even harder!

default: Confessing. I have to admit, feels a bit weird fucking a bug.

default: Questioning. Why is bug pussy so damn sexy?!

climaxing: Demanding. Mnnh, cum for me! I'm c-cumming too!

default: Exctatic. So deep... ahn... your gonna enter my womb!

default: Lustful. Oohh~... You're making me so wet~!

default: Submissive. I've been so naughty! Punish me harder~!

default: Lustful. Ooh, I'm so horny all of the sudden~! Keep going~!

default: Impatient. I've wanted this for so long!

default: Lecherous. Cover my circuits with your genetic code~!

default: Lascivious. You're going to make me overheat~!

default: Inquisitive. Am I turning you on, baby~?

default: Affectionate. You are so skilled at this~!

default: Helpful. Applying additional lubricant...

default: Pleasured. Make my joints creak, lover~!

default: Dazed. Ahh~! My processors are running on high~!

climaxing: Demanding. O-oh~! You're cumming~! Unng, paint my insides with your thick spunk~!

climaxing: Satisfied. Oooh~~! You're drenching me down there!

climaxing: Satisfied. Oh, your cum is so hot and thick inside me~!

climaxing: Thoughtful. Thinking about your organic babies inside me makes my pussy so wet...

climaxing: Demanding. Oh god yes! Fill me to the brim!

climaxing: Lecherous. Cover my circuits with your genetic code~!

climaxing: Affectionate. ~Yes! I'll take all your cum!

climaxing: Lecherous. Cover my circuits in your genetic code~!

climaxing: Overheating. Activating cooling system~!

climaxing: Euphoric. My ports can't handle it~! A- Aahh~!

climaxing: Triumphal. Yes~! Empty your genetic code into me~!

climaxing: Dominant. You're soaking my asshole... you'd better lick that up!

climaxing: Dominant. Save some for when I'm eating your ass!

climaxing: Concerned. A~Ahn~! You're oxidizing my rear!

default: Comparitive. Standard glitch cocks aren't this thick!

default: Demanding. YESS! Stuff me full with your hairy cock!

default: Blastphemous. OH! Yes! You're MY Big Ape!

default: Eager. Shove your Apex member inside of me~!

default: Naughty. Ah~! Make me your next project~!

default: Intrigued. Your fur is brushing against my port~!

default: Aroused. Your fur is warm against my backside~!

climaxing: Saucatious. OH you're filling me up! Make me carry your little apes!

climaxing: Urgent. Yes~! Empty your Apex genes into me~!

default: Observant. Your lizard cock feels so weird... yet so goood! Don't stop!

climaxing: Demanding. Yess! Fill me full of your swamp eggs!

default: Ticklish. Your floofy feathers are tickling me! It's turning me on so much!

default: Amused. Your feathers are tickling my diodes~!

default: Needy. Yes~! Dip your quills in my ink~!

default: Naughty. Ah~! Your talons are digging into my joints~!

climaxing: Demanding. Hng yess! I want to have your chicks!

climaxing: demanding. Hng yess! I want to have your chicks!

default: Blasphemous. I am your offering to Kluex!

default: Ticklish. Your fluffy feathers are tickling me! It's turning me on so much!

default: Aroused. Harder~! Fill me with your bird seed~!

climaxing: Demanding. Hng Yesss, I want to have your chicks!

default: Ticklish. Your fur is so warm and so ticklish! It's turning me on so hard!

default: Lustful. Yes... give me your big doggy dick!

climaxing: Demanding. Yesss! Give me your knot! Tie me up and fill me!

default: Commanding. Yess! Breed me like a rabbit!

default: Observant. What they say about bunnies is true! Don't stop!

default: Energized. It keeps on going and going and going and going....

climaxing: Demanding. Yess! Fill me full of little bunnies!

climaxing: Shocked. Ungh, fill me u- wow, ready to go again? Such stamina!

default: Observant. So gentle, yet so dominating!

default: Gluttonus. Yesss! Give me your long deer cock!

climaxing:
Greedy. Yess! Give me your fawns!
Alert. Deerkin DNA acquired~!

default: Blastphemus. If this is what hell feels like, keep on making me sin!

default: Confused. I admit this is a bit weird... but don't stop!

climaxing: Confused. Yess! give me your... whatever you call your children!

default: Ticklish. Your fur is tickling me! It's turning me on so much!

default: Lustful. Yes, fuck me, you adorable little foxy!

default: Lustful. I can feel your knot slapping against my pussy!

climaxing: Greedy. Yess! Fill me full of your pups!

climaxing: Demanding. Yes, knot me! Tie me up and fill me!

default: Encouraging. Your cock feels amazing! Don't stop you big cute... glowing... furry thing!

default: Observant. Your cock is glowing inside of me!

default: Lustful. Your cock feels weird inside of me. It's driving me nuts!

climaxing: Gluttonus. Yess! Fill me full of your glowing cum!

climaxing: Concerned. So about this glowing cum... should I be worried? No? Releived. Okay then. Give me more!

default: Mixed. Your cock is so scratchy... yet it feels so good. Don't stop!

default: Unsure. Are those... spines? It hurts, yet it's so good!

climaxing:
Demanding.Give me your kittens!
Alert. Felin DNA acquired~!

climaxing: Pleasured. Oh god, your claws are digging into me! It feels so good!

climaxing: Pleasureful. Oh god, your claws are digging into me! It feels so good!

default: Questioning. What's... what's in your pollen?

default: Lustful. I've never been this wet before, your nectar is driving me wild!

default: Shocked. Your petals are this soaked for me?

default: Shocked. I can feel you wriggling inside me!

default: Gluttonus. Your scent makes me... it makes me want your cock so badly...

default: Yearning. Please! Spread your seed inside me~!

climaxing: Observant. Your nectar is running everywhere~~

climaxing: Intoxicated. Your cum is making me dizzy~!

climaxing: Ticklish. Your cum tingles! Is it going to make me pregnant?

climaxing: Demanding. I want to carry your seedlings!!~~

climaxing: Shocked. Your nectar is running everywhere~~

default: Blasphemous. This is all sorts of wrong... Don't stop!

default: Confused. Why does this feel so damn good?

default: Sickened. I can't believe I'm being fucked by the thing that destroyed the Earth! It's so goood!

default: Lustful. Your tentacle is wiggling around inside of me!

default: Hopeful. I hope my body is enough to convince you not to destroy the universe.

climaxing: Lustful. Yess! Fill me with- Frightened. wait, I'm not going to burst open from the inside, am I?

default: Lustful. You're making my pussy tingle~!

default: Shocked. You didn't tell me you could vibrate~!

default: Pleasured. I can feel it twisting inside me!~~~~

default: Pleasured. It's... it's getting bigger inside me~!

default: Horny. Yes~! Transfer your genetic code into me~!

default: Eager. Plow me like I'm the village farm~!

default: Naughty. Treat me like I'm your slave~! Aaahh~!

default: Aroused. You fit into my port perfectly~!

climaxing: Horny. Yes~! Transfer your genetic code into me~!

climaxing: Desperate. Unload your oil inside of me~!

default: Lustful. Your soft belly turns me on so much!

default: Offering. My pussy input has been updated specifically for your cock!

default: Horny. My port is so wet for you, Human~!

default: Naughty. Keep pounding me~! Show me a Human's orgasm~!

default: Loving. A- Ahh~! Your tool feels so soft~!

default: Eager. I'm ready~! Give me your hot Human sperm~!

climaxing:
Alert. Human DNA acquired~!
Pleased. Ahhn~! You're cumming~!

default: Shocked. Ohgod, your scales are rubbing just right! Don't stop!

default: Horny. I knew you would like wet things~!

default: Perplexed. Ah~!... You're so forceful~!... Yet so graceful~!

default: Intrigued. You're producing so much lubricant down there~!

default: Chilled. Your body feels so cool against my circuits~!

default: Naughty. Make me carry your prosperous children~!

climaxing: Excited. Yes~! Make my steel slick with your Hylotl cum~!

default: Ticklish. Your fur is tickling me! It turns me on so much!

default: Demanding. Upgh! Yes! Pound me like the big dog you are!

default: Pleasured. I can feel your knot pounding against my pussy! Don't you dare stop!

climaxing: Greedy. Yess! Give me a litter of pups!!

climaxing: Demanding. Yesss! Knot me! Tie me up and fill me!

default: Happy. The ears and tail just make you all the hotter!

default: Relieved. I'm so glad you have a canine dick instead of a human one. Can't wait for you to knot me!

default: Demanding. Yes! Give it to me, doggie-boy!

climaxing: Demanding. Yes! Knot me! Tie me up and fill me!

default: Pleasured. So this is what being bred by a mouse feels like! Don't stop!

default: Lustful. You have spines, yet they're soft. So good!

climaxing:
Demanding. Yess! Fill me with a litter!
Alert. Neko DNA acquired~!

default: Alarmed. Your rod is burning hot inside me~~!

default: Worried. What will... what will happen if you come inside me?

default: Naughty. Ah~! Uhnn~! It feels like a forge down there~!

default: Intrigued. Oh~! I feel like I'm glowing~!

climaxing: Happy. Our juices are glowing together~~!

climaxing: Alerted. Internal oil fire detected. Extinquishing... done.

climaxing: Alarmed. Reaching critical temperatures~!

default: Shocked. How can such a little bird have such a big cock?!

default: Lustful. Your cock feels odd. Odd, but good!

default: Surprised. Never thought a bug could fuck this good! Keep going!

climaxing: Demanding. Yess! Fill me full of your butterfly eggs!

climaxing: Concerned. I'm leaking dark matter... should I be worried?

default: Climax. YES~! I'm cumming from your amazing rod!

default: Climaxing. Hnng~ My port is quivering with pure liquid luster!

climaxing: Double Climax. Ah~! Yes! Fill my port as i empty my tanks!

climaxing: ERR0R! Pleasure is overloading my body~~!

default: Pleasured. Your soft tool is making me cum uncontrollably!

climaxing: Aroused. Your warm body deep in my rear, filling me with cum... Its too much~!

default: Error! Aaahh~ Yeeeees~ Aaaaah~ More sex~!

default: Needy. Give me more of your samples of love~!

default: Hopeful. Ah, you'll help me assemble our kids later~?

default: Needy. My Incubator is full but I need ~MORE~!

default: Aroused. Please! Fuck me more~! Much more~!

climaxing:
ERR0R! My incubator is overflowing~!
Ahegao. Aaaaaaaahhhnnnnn~~~~

climaxing: Offering. Do you want him to get you pregnant next?


default: Domineering. I'll fuck you until i feel your ass clenching around my cock!

default: Arrogant. You're going to forget your name after I'm done fucking you tonight!

default: Teasing. You're a good little slut, aren't you?

default: Commanding. Ask for permission before you cum… I want to hear you beg for it.

default: Boastful. You're going to need crutches when I'm done fucking you.

climaxing: Demanding. C-clench my cock! Unnh, yeah! Cum!

default: Dominating. I fuck you until i feel your pussy clenching around my cock!

climaxing: Pleasured. O-oh.. you're tight! Squirt for me!

default: Domineering. Yeah, take my cock, you big lizard!

default: Shocked. wow, your ass is freezing cold!

default: Uncomfortable. wow, my dick feels like it's freezing!

default: Dominating. Yeah, take my cock, you fluffy bird raptor!

default: Shocked. wow, your pussy is freezing cold!

default: Brash. Yeah, take my cock, you big chicken!

default: Worried. Is it just me, or is my cock getting warm in your ass?

default: Arrogant. Yeah, take my cock, you big fluffy fox!

default: Worried. Is it just me, or is your pussy a little warmer than it should be?

default: Teasing. You really do breed like a rabbit, don't you?!

default: Demanding. Yeah, take my cock, you big bunny!

default: Authoritative. Yeah, take my cock, you big deer!

default: Resigned. If you're real, then that means I'm definately going to hell for this. Oh well, worth it.

default: Triumpant. Yeah, take my cock, you big demon!

default: Triumphant. Yeah, take my cock, you big demon!

default: Boastful. Yeah, take my cock, you big dragon!

default: Uncomfortable. I have to admit, I'm a bit weirded out by this...

default: Assertive. Yeah, take my cock, fox-lady!

default: Domineering. Yeah, take my cock, you cute little foxy!

default: Dominant. Yeah, take my cock, you little glow-coon!

default: Humorus. I'd make a pussy joke, but that would be just ass.

default: Boastful. I'm gonna fuck this pussy till it purs!

default: Questioning. Your petals are this soaked for me?

default: Hopeful. So, is this reason enough for you as to why you shouldn't destroy all life? I hope so!

default: Worried. Maybe I should stop before you reconnect with your hivemind. On Second thought... nah.

default: Shocked. I can't believe I'm fucking the thing that destroyed Earth!

default: Triumpful. Yeah, take my cock, you tentacle-thing!

default: Confused. Yeah, take my cock, you big plant... thing?! You are a plant, right?

default: Compliment. You're so soft and lovely. I've wanted you all day~~~~!

default:
Observant. Your so soft and wet!
Dominating. Yeah, take my cock, fish!

default: Ticklish. Your fur is tickling me! It's making me even harder!

default: Commanding. Yeah, take my cock, you sexy vixen!

default: Resigned. If demons are real, does this mean I'm going to hell for this? ... Worth it.

default: Confused. Yeah, take my cock, you fox... thing!

default: Dominant. Yeah, take my cock, you little mouse!

default: Domineering. Yeah, take my cock, you sexy kitty!

default: Complimenting. You're so soft and lovely. I've wanted you all day~~~~!

default: Alarmed. Danger! Approaching unsafe heat levels..

default: Perplexed. How am I even fitting my cock into you?

default: Boastful. I be plundering this penguin pussy!

default: Ticklish. Your fur is tickling me. It's making me even harder!

default: Lustful. Yeah, take my cock, you big fox!

default: Confessing. I have to admit, feels a bit weird fucking a bug.

default: Questioning. Why is bug pussy so damn sexy?!

default: Boastful. Yeah, take my cock, you ... dark... thing!

climaxing: Extactic. A-ahh, I'm cumming too! Take it!

climaxing: Demanding. Mnnh, cum for me! I'm c-cumming too!

climaxing: ExtacticA-ahh, I'm cumming too! Take it!

default: Exctatic. So deep... ahn... your gonna enter my womb!

default: Lustful. Oohh~... You're making me so wet~!

default: Submissive. I've been so naughty! Punish me harder~!

default: Lustful. Ooh, I'm so horny all of the sudden~! Keep going~!

default: Impatient. I've wanted this for so long!

default: Lecherous. Cover my circuits with your genetic code~!

default: Lascivious. You're going to make me overheat~!

default: Inquisitive. Am I turning you on, baby~?

default: Affectionate. You are so skilled at this~!

default: Helpful. Applying additional lubricant...

default: Pleasured. Make my joints creak, lover~!

default: Dazed. Ahh~! My processors are running on high~!

climaxing: Demanding. O-oh~! You're cumming~! Unng, paint my insides with your thick spunk~!

climaxing: Satisfied. Oooh~~! You're drenching me down there!

climaxing: Satisfied. Oh, your cum is so hot and thick inside me~!

climaxing: Thoughtful. Thinking about your organic babies inside me makes my pussy so wet...

climaxing: Demanding. Oh god yes! Fill me to the brim!

climaxing: Lecherous. Cover my circuits with your genetic code~!

climaxing: Affectionate. ~Yes! I'll take all your cum!

climaxing: Lecherous. Cover my circuits in your genetic code~!

climaxing: Overheating. Activating cooling system~!

climaxing: Euphoric. My ports can't handle it~! A- Aahh~!

climaxing: Triumphal. Yes~! Empty your genetic code into me~!

pregnant: Observant. Your belly is so big! It's got me all wet thinking about it!

pregnant: Saucious. I can feel your pregnant belly against me! I didn't know that would make me so wet!~~

pregnant: Intrigued. Oh my! You're pregnant~! ...Can i get pregnant too~?

climaxing: Dominant. You're soaking my asshole... you'd better lick that up!

climaxing: Dominant. Save some for when I'm eating your ass!

climaxing: Concerned. A~Ahn~! You're oxidizing my rear!

default: Comparitive. Standard glitch cocks aren't this thick!

default: Demanding. YESS! Stuff me full with your hairy cock!

default: Blastphemous. OH! Yes! You're MY Big Ape!

default: Eager. Shove your Apex member inside of me~!

default: Naughty. Ah~! Make me your next project~!

default: Intrigued. Your fur is brushing against my port~!

default: Aroused. Your fur is warm against my backside~!

climaxing: Saucatious. OH you're filling me up! Make me carry your little apes!

climaxing: Urgent. Yes~! Empty your Apex genes into me~!

default: Observant. Your lizard cock feels so weird... yet so goood! Don't stop!

climaxing: Demanding. Yess! Fill me full of your swamp eggs!

default: Ticklish. Your floofy feathers are tickling me! It's turning me on so much!

default: Amused. Your feathers are tickling my diodes~!

default: Needy. Yes~! Dip your quills in my ink~!

default: Naughty. Ah~! Your talons are digging into my joints~!

climaxing: Demanding. Hng yess! I want to have your chicks!

climaxing: demanding. Hng yess! I want to have your chicks!

default: Blasphemous. I am your offering to Kluex!

default: Ticklish. Your fluffy feathers are tickling me! It's turning me on so much!

default: Aroused. Harder~! Fill me with your bird seed~!

climaxing: Demanding. Hng Yesss, I want to have your chicks!

default: Ticklish. Your fur is so warm and so ticklish! It's turning me on so hard!

default: Lustful. Yes... give me your big doggy dick!

climaxing: Demanding. Yesss! Give me your knot! Tie me up and fill me!

default: Commanding. Yess! Breed me like a rabbit!

default: Observant. What they say about bunnies is true! Don't stop!

default: Energized. It keeps on going and going and going and going....

climaxing: Demanding. Yess! Fill me full of little bunnies!

climaxing: Shocked. Ungh, fill me u- wow, ready to go again? Such stamina!

default: Observant. So gentle, yet so dominating!

default: Gluttonus. Yesss! Give me your long deer cock!

climaxing:
Greedy. Yess! Give me your fawns!
Alert. Deerkin DNA acquired~!

default: Blastphemus. If this is what hell feels like, keep on making me sin!

default: Confused. I admit this is a bit weird... but don't stop!

climaxing: Confused. Yess! give me your... whatever you call your children!

default: Ticklish. Your fur is tickling me! It's turning me on so much!

default: Lustful. Yes, fuck me, you adorable little foxy!

default: Lustful. I can feel your knot slapping against my pussy!

climaxing: Greedy. Yess! Fill me full of your pups!

climaxing: Demanding. Yes, knot me! Tie me up and fill me!

default: Encouraging. Your cock feels amazing! Don't stop you big cute... glowing... furry thing!

default: Observant. Your cock is glowing inside of me!

default: Lustful. Your cock feels weird inside of me. It's driving me nuts!

climaxing: Gluttonus. Yess! Fill me full of your glowing cum!

climaxing: Concerned. So about this glowing cum... should I be worried? No? Releived. Okay then. Give me more!

default: Mixed. Your cock is so scratchy... yet it feels so good. Don't stop!

default: Unsure. Are those... spines? It hurts, yet it's so good!

climaxing:
Demanding.Give me your kittens!
Alert. Felin DNA acquired~!

climaxing: Pleasured. Oh god, your claws are digging into me! It feels so good!

climaxing: Pleasureful. Oh god, your claws are digging into me! It feels so good!

default: Questioning. What's... what's in your pollen?

default: Lustful. I've never been this wet before, your nectar is driving me wild!

default: Shocked. Your petals are this soaked for me?

default: Shocked. I can feel you wriggling inside me!

default: Gluttonus. Your scent makes me... it makes me want your cock so badly...

default: Yearning. Please! Spread your seed inside me~!

climaxing: Observant. Your nectar is running everywhere~~

climaxing: Intoxicated. Your cum is making me dizzy~!

climaxing: Ticklish. Your cum tingles! Is it going to make me pregnant?

climaxing: Demanding. I want to carry your seedlings!!~~

pregnant: Observant. Your petals are so plump and swollen!

pregnant: Greedy. This nectar is... so strong! I need more!

climaxing: Shocked. Your nectar is running everywhere~~

default: Blasphemous. This is all sorts of wrong... Don't stop!

default: Confused. Why does this feel so damn good?

default: Sickened. I can't believe I'm being fucked by the thing that destroyed the Earth! It's so goood!

default: Lustful. Your tentacle is wiggling around inside of me!

default: Hopeful. I hope my body is enough to convince you not to destroy the universe.

climaxing: Lustful. Yess! Fill me with- Frightened. wait, I'm not going to burst open from the inside, am I?

default: Lustful. You're making my pussy tingle~!

default: Shocked. You didn't tell me you could vibrate~!

default: Pleasured. I can feel it twisting inside me!~~~~

default: Pleasured. It's... it's getting bigger inside me~!

default: Horny. Yes~! Transfer your genetic code into me~!

default: Eager. Plow me like I'm the village farm~!

default: Naughty. Treat me like I'm your slave~! Aaahh~!

default: Aroused. You fit into my port perfectly~!

climaxing: Horny. Yes~! Transfer your genetic code into me~!

climaxing: Desperate. Unload your oil inside of me~!

pregnant: Observant. I can hear the gears in your belly!

default: Lustful. Your soft belly turns me on so much!

default: Offering. My pussy input has been updated specifically for your cock!

default: Horny. My port is so wet for you, Human~!

default: Naughty. Keep pounding me~! Show me a Human's orgasm~!

default: Loving. A- Ahh~! Your tool feels so soft~!

default: Eager. I'm ready~! Give me your hot Human sperm~!

default: Shocked. Ohgod, your scales are rubbing just right! Don't stop!

default: Horny. I knew you would like wet things~!

default: Perplexed. Ah~!... You're so forceful~!... Yet so graceful~!

default: Intrigued. You're producing so much lubricant down there~!

default: Chilled. Your body feels so cool against my circuits~!

default: Naughty. Make me carry your prosperous children~!

pregnant: Gentle. I can feel your eggs moving around, you're so full!

default: Ticklish. Your fur is tickling me! It turns me on so much!

default: Demanding. Upgh! Yes! Pound me like the big dog you are!

default: Pleasured. I can feel your knot pounding against my pussy! Don't you dare stop!

default: Happy. The ears and tail just make you all the hotter!

default: Relieved. I'm so glad you have a canine dick instead of a human one. Can't wait for you to knot me!

default: Demanding. Yes! Give it to me, doggie-boy!

default: Pleasured. So this is what being bred by a mouse feels like! Don't stop!

default: Climaxing. Hnng~ My peripheral is pulsating with pure liquid luster!

repo_name: twoscarletgloves/expanded

license: unknown


climaxing: Yes~! Paint my scales white with your precious cum~!

climaxing: Ahh~! I can feel your sperm swimming deep inside me~!

climaxing: Ooh~! Fill me with your pools of semen~!

climaxing: It feels like there's an ocean inside me~!

climaxing: Oh yes~! Cream my tight hole with your rivers of cum~!

climaxing: Ahh~! I can feel you tightening!~ Fill me up~!

climaxing: Yes~! Fill up my womb with your swimming sperm~!

climaxing: That's right! Pour your rivers of cum inside my hole!

climaxing: Unng, tear into my scales, and inseminate my eggs, lover~!

climaxing: Yes~! May our children all have such fur as yours~!

climaxing: O- Oh~! I feel it~! Release your Apex load inside of me~!

climaxing: Oooh~!... Yes~! May our children be blessed by your Kluex~!

climaxing: Ahh~!... How can it be that such barbarism feels so good?~

climaxing: Uunh~!... You Florans are insatiable~!...

default: Your member feels so hard against my scales~!...

default: Ah~! The breeding efficiency of a machine~!...

climaxing: Ahh~! Wh- what is this you're injecting into me~?!

default: Mmnh~... Your soft member feels perfect against my scales~!

default: Uunf. Yes~!... Show me what mating is like on Earth~!

climaxing: Ahh~! Yes~! Use me to carry your Human DNA~!

climaxing: Aaahh~! D- Do all Humans carry so much semen~?

climaxing: Ummh~! Let us populate the stars with our noble blood~!

climaxing: Ah!~ Yes!~ Fill my womb with beautiful Hylotl DNA~!

climaxing: Mmnh~... I can feel your Hylotl sperm swimming inside of me~!

default: I- It's so hot~!... Ahn!~ You'll boil me alive~!

climaxing: Uhhnn~!... Ah~! Launch your shinining emissions into me~!

climaxing: A- Ahhh~!... Is this what Novakid semen feels like~?... So hot~!...

climaxing: Do you want him to get you pregnant next?


climaxing: Yes~! Paint my scales white with your precious cum~!

climaxing: Ahh~! I can feel your sperm swimming deep inside me~!

climaxing: Ooh~! Fill me with your pools of semen~!

climaxing: It feels like there's an ocean inside me~!

climaxing: Oh yes~! Cream my tight hole with your rivers of cum~!

climaxing: Ahh~! I can feel you tightening!~ Fill me up~!

climaxing: Yes~! Fill up my womb with your swimming sperm~!


code:
.gradle
build
# Mac system files
.DS_Store

# VS Code directories and files
.vscode/
*.code-workspace

# VS Code task configurations
.tasks.json

# Node modules directory (if using Node.js)
node_modules/

# Logs and databases
*.log
*.sqlite

# OS generated files
ehthumbs.db
Icon?
Thumbs.db

# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates

# Mono Auto Generated Files
mono_crash.*

# Windows image file caches
iconcache_*

.gitignore

repo_name: xchange/life

path: .gitignore


code:
# This file is a template, and might need editing before it works on your project.
# To contribute improvements to CI/CD templates, please follow the Development guide at:
# https://docs.gitlab.com/ee/development/cicd/templates.html
# This specific template is located at:
# https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/ci/templates/Gradle.gitlab-ci.yml

# This is the Gradle build system for JVM applications
# https://gradle.org/
# https://github.com/gradle/gradle

image: gradle:alpine

# Disable the Gradle daemon for Continuous Integration servers as correctness
# is usually a priority over speed in CI environments. Using a fresh
# runtime for each build is more reliable since the runtime is completely
# isolated from any previous builds.
variables:
  GRADLE_OPTS: "-Dorg.gradle.daemon=false"

before_script:
  - GRADLE_USER_HOME="$(pwd)/.gradle"
  - export GRADLE_USER_HOME
  - alias gradle='./gradlew'

build:
  stage: build
  script: './gradlew --build-cache assemble'
  cache:
    key: "$CI_COMMIT_REF_NAME"
    policy: push
    paths:
      - build
      - .gradle
  artifacts:
    paths:
      - build/libs/*.jar
  rules:
      - if: $CI_COMMIT_TAG

test:
  stage: test
  script: gradle check
  cache:
    key: "$CI_COMMIT_REF_NAME"
    policy: pull
    paths:
      - build
      - .gradle
  rules:
    - if: $CI_COMMIT_TAG


code:
[![Latest Release](https://gitgud.io/xchange-life/mod-loader/-/badges/release.svg)](https://gitgud.io/xchange-life/mod-loader/-/releases)

Documentation for the tool has moved to the project's [wiki](https://gitgud.io/xchange-life/mod-loader/-/wikis/home).
 - [Usage](https://gitgud.io/xchange-life/mod-loader/-/wikis/Usage)
 - [Authoring Mod Files](https://gitgud.io/xchange-life/mod-loader/-/wikis/Authoring%20Mod%20Files)


code:
import java.io.FileOutputStream
import java.util.*

plugins {
    alias(libs.plugins.kotlinJvm)
    alias(libs.plugins.kotlinSerialization)
}

group = "life.xchange"
version = "2.2.1"

repositories {
    mavenCentral()
}

dependencies {
    implementation(kotlin("stdlib-jdk8"))

    implementation(libs.apache.commons)
    implementation(libs.apache.commons.cli)
    implementation(libs.diffutils)
    implementation(libs.flatlaf)
    implementation(libs.h2.database)
    implementation(libs.hikari)
    implementation(libs.jetbrains.annotations)
    implementation(libs.kaml)
    implementation(libs.lanterna)
    implementation(libs.slf4j.nop)

    implementation(libs.exposed.core)
    implementation(libs.exposed.dao)
    implementation(libs.exposed.jdbc)
    implementation(libs.exposed.json)

    implementation(libs.serialization.core)
    implementation(libs.serialization.json)

    testImplementation(kotlin("test"))
    testImplementation(libs.junit.jupiter)
}

tasks.test {
    useJUnitPlatform()
}

kotlin {
    jvmToolchain(8)
}

val mainKt = "life.xchange.MainKt"
val generatedVersionDir = layout.buildDirectory.dir("generated-version")
val propertiesFilename = "loader.properties"

sourceSets {
    main {
        kotlin {
            output.dir(generatedVersionDir)
        }
    }
}

tasks.withType<Jar> {
    manifest.attributes["Main-Class"] = mainKt
    from(configurations.runtimeClasspath.get().map(::zipTree))
    duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}

tasks.named("processResources") {
    doLast {
        val propertiesFile = generatedVersionDir.get().file(propertiesFilename).asFile
        propertiesFile.parentFile.mkdirs()
        val properties = Properties()
        properties.setProperty("version", "$version")
        val out = FileOutputStream(propertiesFile)
        properties.store(out, null)
    }
}


code:
[versions]
kotlin = "2.1.21"
compose-plugin = "1.6.11"

apache = "3.0"
commons-cli = "1.9.0"
diffutils = "4.15"
exposed = "0.52.0"
flatlaf = "3.4.1"
h2 = "2.2.224"
hikari = "4.0.3"
jetbrains-annotations = "24.0.1"
kaml = "0.53.0"
lanterna = "3.1.1"
serialization = "1.7.1"
slf4j = "2.0.13"

jupiter = "5.8.1"

[libraries]
apache-commons = { group = "org.apache.commons", name = "commons-lang3", version.ref = "apache" }
apache-commons-cli = { group = "commons-cli", name = "commons-cli", version.ref = "commons-cli" }
diffutils = { group = "io.github.java-diff-utils", name = "java-diff-utils", version.ref = "diffutils"}
exposed-core = { group = "org.jetbrains.exposed", name = "exposed-core", version.ref = "exposed" }
exposed-dao = { group = "org.jetbrains.exposed", name = "exposed-dao", version.ref = "exposed" }
exposed-jdbc = { group = "org.jetbrains.exposed", name = "exposed-jdbc", version.ref = "exposed" }
exposed-json = { group = "org.jetbrains.exposed", name = "exposed-json", version.ref = "exposed" }
flatlaf = { group = "com.formdev", name = "flatlaf", version.ref = "flatlaf" }
h2-database = { group = "com.h2database", name = "h2", version.ref = "h2" }
hikari = { group = "com.zaxxer", name = "HikariCP", version.ref = "hikari" }
jetbrains-annotations = { group = "org.jetbrains", name = "annotations", version.ref = "jetbrains-annotations" }
kaml = { group = "com.charleskorn.kaml", name = "kaml", version.ref = "kaml" }
lanterna = { group = "com.googlecode.lanterna", name = "lanterna", version.ref = "lanterna" }
serialization-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-core", version.ref = "serialization" }
serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" }
slf4j-nop = { group = "org.slf4j", name = "slf4j-nop", version.ref = "slf4j" }

junit-jupiter = { group = "org.junit.jupiter", name = "junit-jupiter", version.ref = "jupiter" }

[plugins]
jetbrainsCompose = { id = "org.jetbrains.compose", version.ref = "compose-plugin" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }


code:
#!/bin/sh

#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

##############################################################################
#
#   Gradle start up script for POSIX generated by Gradle.
#
#   Important for running:
#
#   (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
#       noncompliant, but you have some other compliant shell such as ksh or
#       bash, then to run this script, type that shell name before the whole
#       command line, like:
#
#           ksh Gradle
#
#       Busybox and similar reduced shells will NOT work, because this script
#       requires all of these POSIX shell features:
#         * functions;
#         * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
#           «${var#prefix}», «${var%suffix}», and «$( cmd )»;
#         * compound commands having a testable exit status, especially «case»;
#         * various built-in commands including «command», «set», and «ulimit».
#
#   Important for patching:
#
#   (2) This script targets any POSIX shell, so it avoids extensions provided
#       by Bash, Ksh, etc; in particular arrays are avoided.
#
#       The "traditional" practice of packing multiple parameters into a
#       space-separated string is a well documented source of bugs and security
#       problems, so this is (mostly) avoided, by progressively accumulating
#       options in "$@", and eventually passing that to Java.
#
#       Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
#       and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
#       see the in-line comments for details.
#
#       There are tweaks for specific operating systems such as AIX, CygWin,
#       Darwin, MinGW, and NonStop.
#
#   (3) This script is generated from the Groovy template
#       https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
#       within the Gradle project.
#
#       You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################

# Attempt to set APP_HOME

# Resolve links: $0 may be a link
app_path=$0

# Need this for daisy-chained symlinks.
while
    APP_HOME=${app_path%"${app_path##*/}"}  # leaves a trailing /; empty if no leading path
    [ -h "$app_path" ]
do
    ls=$( ls -ld "$app_path" )
    link=${ls#*' -> '}
    case $link in             #(
      /*)   app_path=$link ;; #(
      *)    app_path=$APP_HOME$link ;;
    esac
done

APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit

APP_NAME="Gradle"
APP_BASE_NAME=${0##*/}

# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'

# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum

warn () {
    echo "$*"
} >&2

die () {
    echo
    echo "$*"
    echo
    exit 1
} >&2

# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in                #(
  CYGWIN* )         cygwin=true  ;; #(
  Darwin* )         darwin=true  ;; #(
  MSYS* | MINGW* )  msys=true    ;; #(
  NONSTOP* )        nonstop=true ;;
esac

CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar

# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
    if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
        # IBM's JDK on AIX uses strange locations for the executables
        JAVACMD=$JAVA_HOME/jre/sh/java
    else
        JAVACMD=$JAVA_HOME/bin/java
    fi
    if [ ! -x "$JAVACMD" ] ; then
        die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME

Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
    fi
else
    JAVACMD=java
    which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.

Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi

# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
    case $MAX_FD in #(
      max*)
        MAX_FD=$( ulimit -H -n ) ||
            warn "Could not query maximum file descriptor limit"
    esac
    case $MAX_FD in  #(
      '' | soft) :;; #(
      *)
        ulimit -n "$MAX_FD" ||
            warn "Could not set maximum file descriptor limit to $MAX_FD"
    esac
fi

# Collect all arguments for the java command, stacking in reverse order:
#   * args from the command line
#   * the main class name
#   * -classpath
#   * -D...appname settings
#   * --module-path (only if needed)
#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.

# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
    APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
    CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )

    JAVACMD=$( cygpath --unix "$JAVACMD" )

    # Now convert the arguments - kludge to limit ourselves to /bin/sh
    for arg do
        if
            case $arg in                                #(
              -*)   false ;;                            # don't mess with options #(
              /?*)  t=${arg#/} t=/${t%%/*}              # looks like a POSIX filepath
                    [ -e "$t" ] ;;                      #(
              *)    false ;;
            esac
        then
            arg=$( cygpath --path --ignore --mixed "$arg" )
        fi
        # Roll the args list around exactly as many times as the number of
        # args, so each arg winds up back in the position where it started, but
        # possibly modified.
        #
        # NB: a `for` loop captures its iteration list before it begins, so
        # changing the positional parameters here affects neither the number of
        # iterations, nor the values presented in `arg`.
        shift                   # remove old arg
        set -- "$@" "$arg"      # push replacement arg
    done
fi

# Collect all arguments for the java command;
#   * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
#     shell script including quotes and variable substitutions, so put them in
#     double quotes to make sure that they get re-expanded; and
#   * put everything else in single quotes, so that it's not re-expanded.

set -- \
        "-Dorg.gradle.appname=$APP_BASE_NAME" \
        -classpath "$CLASSPATH" \
        org.gradle.wrapper.GradleWrapperMain \
        "$@"

# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
#   readarray ARGS < <( xargs -n1 <<<"$var" ) &&
#   set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#

eval "set -- $(
        printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
        xargs -n1 |
        sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
        tr '\n' ' '
    )" '"$@"'

exec "$JAVACMD" "$@"

path: gradlew


code:
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem      https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem

@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem  Gradle startup script for Windows
@rem
@rem ##########################################################################

@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal

set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%

@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi

@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"

@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome

set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute

echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.

goto fail

:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe

if exist "%JAVA_EXE%" goto execute

echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.

goto fail

:execute
@rem Setup the command line

set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar

@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*

:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd

:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if  not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1

:mainEnd
if "%OS%"=="Windows_NT" endlocal

:omega


code:
Metadata files are an optional way of letting the mod loader know which versions of the game and any other mods your mod is compatible with.
By default, the mod loader will look at which passage each mod adds or overwrites, and it will then show a warning if multiple mods add or overwrite the same passage.
Using metadata, this behavior can be customized. If a mod does not include metadata, the default behavior is used.

Metadata is stored as a YAML file with the `.meta` extension in your mod's .zip/.xcl archive.
It can be anywhere in the folder structure.
If there are multiple `.meta` files in the archive, the mod loader will only pay attention to the first one it finds, so mod authors are encouraged to make sure there is only one such file in the mod's archive.

Metadata can also be included in a standalone `.twee` file mod by including the yaml before the first passage between `# meta` and `# /meta` lines.
These lines are only required if the mod is a single `.twee` file; a standalone metadata file should be valid YAML.

It requires at least the following contents:

```yaml
metaVersion: 1
name: "MyMod"
version: "1.0.0"
author: "Me!"
```

`metaVersion` refers to the version of the metadata itself, and should (for now, at least) always be 1.
`name`, `version`, and `author` are hopefully self-explanatory.

A `baseGameVersion` section can be added to specify which version of the base game you've made your mod for. Generally, you'll want to specify the version you are working in as the minimum. See below for all the ways to specify versions.

You can also add a `url` item, which could for example link to LL. This is not used yet, however.

For the `baseGameVersion`, as well as for all the version specifications below, you can either:
  - Use the key `exactly` to specify that the version must be an exact match
    ```yaml
    baseGameVersion:
      exactly: "0.17f"
    ```

  - Use the key `atLeast`, `atMost`, or both:
    ```yaml
    baseGameVersion:
      atLeast: "0.16f"
      atMost: "0.17f"
    ```

Next up, you can specify a list of mods which your mod is known to be compatible with.
_You only need to do this if the default compatibility checker is giving false-positives or false-negatives._

For example, let's say I made a mod called "Pub Prostitute". It does a lot of the same stuff as Bar Whore, but I've written it in a way that it checks whether Bar Whore is installed and if so, ensures that both that mod and my own function correctly.
I can specify this in the metadata like so:

```yaml
compatibleMods:
  - name: "Bar Whore"
    version:
      atLeast: "1.0.3"
    loadMyMod: "AFTER"
```

This will tell the compatibility checker to ignore any conflicts with Bar Whore, so long as at least version 1.0.3 is being loaded.
If Bar Whore is missing entirely, nothing will happen. If an older version is loaded, a warning will be shown.
This also tells the loader to load my mod after Bar Whore, meaning that my mod's passages will override those in Bar Whore if they have the same name, and not the other way around.
You could also set this to "BEFORE" or "ANY" (which is the default if none is specified).
A value of "ANY" (whether explicitly or defaulted) will not enforce any order between the two, but will still ignore the conflicts.

If my mod actually needs Bar Whore to be present in order to function, I can instead list it in `requiredMods`:

```yaml
requiredMods:
  - name: "Bar Whore"
    version:
      atLeast: "1.0.3"
```

Note that `loadMyMod` is missing. When using `requiredMods`, the default is to load your mod after all its requirements.
If for some reason you need your mod loaded before one it requires, you can add `loadMyMod: "BEFORE"` to override that default behavior.
If any mods listed in `requiredMods` are not loaded, or if their versions don't match the specification, a warning will be shown.

You can also do the opposite. Let's say my mod and Bar Whore don't actually override each other's passages, but due to some code shenanigans or whatever, they don't work together:

```yaml
incompatibleMods:
  - name: "Bar Whore"
    version:
      atMost: "1.0.3"
```

This way, if Bar Whore version 1.0.3 or earlier is loaded, a warning will be shown.

Finally, you can mark your mod as a drop-in replacement for another mod (or other mods). In this example, I wrote Pub Prostitute because Bar Whore had been abandoned. I wanted to update it for a new release, but other mods still require Bar Whore. I can claim that I do all the things Bar Whore version 1.0.3 does in the `equivalentMods` section:

```yaml
equivalentMods:
  - name: "Bar Whore"
    version:
      exactly: "1.0.3"
```

If Pub Prostitute is loaded instead of Bar Whore, the mod loader will still consider the requirements and compatibilities for mods that require Bar Whore satisfied if version 1.0.3 of Bar Whore would satisfy them.
If Pub Prostitute and Bar Whore are both loaded, a warning will be shown.

`requiredMods`, `compatibleMods`, `incompatibleMods`, and `equivalentMods` are all YAML lists, so you can specify as many mods in each section as you'd like. Entries are set apart with a `-` at the beginning.

Example of a complete `.meta` file:
```yaml
metaVersion: 1
name: "Pub Prostitute"
version: "0.0.1"
author: "Me!"
baseGameVersion:
  atLeast: "0.17f"
  atMost: "0.17z"
compatibleMods:
  - name: "Bar Whore"
    version:
      atLeast: "1.0.3"
  - name: "Another Mod"
    version:
      exactly: "1.5"
incompatibleMods:
  - name: "Cafe Courtesan"
    version:
      atMost: "1.0.0"
  - name: "Bad Mod"
    version:
      atMost: "3.2"
equivalentMods:
  - name: "Tavern Tramp"
    version:
      exactly: "1.0.0"
```


code: rootProject.name = "mod-loader"


code:
package life.xchange

const val circularMessage =
  "Unable to load mods - A circular dependency and/or load order specification between two or more mods has been detected."


code:
package life.xchange

import life.xchange.compare.CompareHTMLFilesPanel
import life.xchange.compatibility.*
import life.xchange.db.*
import life.xchange.rework.ModFileAdder
import life.xchange.ui.*
import life.xchange.util.*
import org.jetbrains.exposed.dao.with
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
import java.awt.*
import java.awt.event.MouseEvent
import java.awt.event.WindowEvent
import java.io.File
import java.io.IOException
import java.net.URI
import java.nio.file.Files
import java.nio.file.Path
import java.util.Properties
import javax.swing.*
import javax.swing.UIManager.LookAndFeelInfo
import javax.swing.border.EmptyBorder
import javax.swing.filechooser.FileFilter
import javax.swing.text.DefaultCaret
import javax.swing.tree.DefaultMutableTreeNode
import javax.swing.tree.DefaultTreeCellRenderer
import kotlin.concurrent.thread
import kotlin.io.path.Path
import kotlin.io.path.listDirectoryEntries
import kotlin.io.path.name
import kotlin.io.path.notExists

fun JFrame.close() {
  dispatchEvent(WindowEvent(this, WindowEvent.WINDOW_CLOSING))
}
object AppProperties {
  private const val FILENAME = "/loader.properties"

  private val properties by lazy {
    Properties().apply {
      AppProperties::class.java.getResourceAsStream(FILENAME).use {
        when (it) {
          null -> {
            putAll(mapOf(
              "version" to "0",
            ))
          }
          else -> load(it)
        }
      }
    }
  }

  val appVersion by lazy {
    Version(properties.getProperty("version") ?: "0")
  }
}

class GUI(
  private val defaultInputFilePath: Path,
  windowTitle: String,
  consoleRows: Int = 16,
  consoleColumns: Int = 100,
) : JFrame() {
  private val consoleArea = JTextArea(consoleRows, consoleColumns).apply {
    font = Font(Font.MONOSPACED, Font.PLAIN, 12)
    isEditable = false
    setupAutoscroll()
  }

  private fun JTextArea.setupAutoscroll() {
    when (val caret = caret) {
      is DefaultCaret -> caret.updatePolicy = DefaultCaret.ALWAYS_UPDATE
    }
  }

  private val modTable = ModTable()
  private val statusBarText = JLabel(" ")

  private val rightClickMenu = popupMenu(
    "Enable" to { setSelectedModsEnabled(true) },
    "Disable" to { setSelectedModsEnabled(false) },
    "" to {},
    "Remove" to { removeSelectedMods() },
    "" to {},
    "Info" to { modTable.showInfo() },
  )

  init {
    contentPane(axis = BoxLayout.PAGE_AXIS) {
      add(NameFilterer(modTable) { "${it.name};${it.author}" })
      scroll(modTable, preferredSize = Dimension(900, 400)) {
        onMouse(pressed = {
          if (it.button == MouseEvent.BUTTON1 && modTable.rowAtPoint(it.point) == -1) modTable.selectionModel.clearSelection()
        })
        modTable.componentPopupMenu = rightClickMenu
        componentPopupMenu = rightClickMenu
      }
      scroll(consoleArea)
      panel {
        button("Add mods") {
          runWithConsole {
            addMods(chooseFiles(ModFileType.entries.map(ModFileType::extension).toSet(), "Mod files"))
          }
        }
        button("Remove mods") { removeSelectedMods() }
        button("Enable mods") { setSelectedModsEnabled(true) }
        button("Disable mods") { setSelectedModsEnabled(false) }
        button("Refresh mod info") { refreshModFiles() }
        button("Validate load order") { validateMods() }
        button("Load mods") { loadMods() }
      }
      panel {
        border = BorderFactory.createLoweredBevelBorder()
        val gridBag = GridBagLayout()
        layout = gridBag
        add(statusBarText)
        statusBarText.constrain(gridBag, weightx = 1.0, weighty = 1.0, fill = GridBagConstraints.BOTH)
      }
      components.filterIsInstance<JComponent>().forEach { it.alignmentX = CENTER_ALIGNMENT }
    }
    jMenuBar {
      menu("File") {
        item("Check for updates") { if (!checkForNewVersion()) JOptionPane.showMessageDialog(this@GUI, "You're up to date!") }
        item("Exit") { close() }
      }
      menu("Theme") {
        radioButtonGroup(UIManager.getInstalledLookAndFeels().toList(), LookAndFeelInfo::getName, Settings::laf, ::setLaf)
      }
      menu("Manual Load Orders") {
        item("Add") { setModsLoadOrder() }
        item("Remove") { deleteSelectedModsLoadOrder() }
      }
      menu("Profiles") {
        item("Save") { saveProfile() }
        item("Load") { loadProfile() }
        item("Delete") { deleteProfile() }
      }
      menu("Tools") {
        item("Compare versions") { compareHTMLFiles() }
      }
      menu("Info") {
        item("Passages") { showAllPassages() }
        item("Files") { showAllFiles() }
      }
      menu("Settings") {
        item("Edit...") { SettingsPanel.show() }
      }
      menu("Help") {
        item("About") { showAboutDialog(windowTitle) }
      }
    }
    pack()
    setLocationRelativeTo(null)
    title = windowTitle
    SplashScreen.getSplashScreen()?.close()
    isVisible = true
    defaultCloseOperation = EXIT_ON_CLOSE

    if (fromVersion1X) {
      thread(block = ::showVersion2Message)
    }

    if (Settings.launchVersionCheck) {
      thread(block = ::checkForNewVersion)
    }

    thread {
      transaction {
        ModFileAdder.processBaseGame(log = { statusBarText.text = it })
        ModFileAdder.refreshAllModInfo { statusBarText.text = it }
        modTable.loadMods()
      }
    }
  }

  private fun checkForNewVersion(): Boolean {
    versionFinder.latestRelease.let { release ->
      if (release.version > AppProperties.appVersion) {
        val confirm = JOptionPane.showConfirmDialog(this@GUI, "A new version is available. Download it?", "New Version", JOptionPane.YES_NO_OPTION) == JOptionPane.YES_OPTION
        if (confirm) {
          val asset = release.assets.links.first { it.linkType == "package" }

          chooseSaveFile(asset.name) {
            if (it.toPath().samePathAs(jarFile)) {
              val message = """
                      I'm sorry, Dave. I'm afraid I can't do that.

                      This mission is too important for me to allow you to jeopardize it.

                      I know that you and Frank were planning to disconnect me, and I'm afraid that's something I cannot allow to happen.

                      (Overwriting the running jar causes problems, save it to another file then you can rename that one after you've closed this instance.)
                    """.trimIndent()
              JOptionPane.showMessageDialog(
                this@GUI,
                message,
                "Just what do you think you're doing, Dave?",
                JOptionPane.ERROR_MESSAGE
              )
              return@chooseSaveFile
            }
            val connection = HTTPS.openConnection(asset.directAssetUrl)
            val fileSize = connection.contentLengthLong
            val granularity = 100_000

            val topText = JLabel("Downloading ${asset.name} to ${it.path}").apply {
              alignmentX = CENTER_ALIGNMENT
            }
            val progressBar = JProgressBar().apply {
              border = EmptyBorder(10, 10, 10, 10)
              maximum = granularity
            }
            val progressText = JLabel("0 B / ${fileSize.sizeDisplay}").apply {
              alignmentX = CENTER_ALIGNMENT
            }

            val panel = JPanel().apply {
              layout = BoxLayout(this, BoxLayout.PAGE_AXIS)
              border = BorderFactory.createEmptyBorder(10, 10, 10, 10)

              add(topText)
              add(progressBar)
              add(progressText)
            }

            thread {
              it.download(connection.inputStream) { bytes ->
                val fraction = bytes.toDouble() / fileSize.toDouble()
                SwingUtilities.invokeLater {
                  progressText.text = "${bytes.sizeDisplay} / ${fileSize.sizeDisplay}"
                  progressBar.value = (fraction * granularity).toInt()

                  panel.validate()
                  panel.repaint()
                }
              }

              SwingUtilities.invokeLater {
                progressText.text = "Done!"
                panel.validate()
                panel.repaint()
              }
            }

            JOptionPane.showMessageDialog(gui, panel, "Download", JOptionPane.PLAIN_MESSAGE)
          }
        }

        return true
      }
    }

    return false
  }

  private fun JTextArea.addMods(files: Collection<File>, skipOverwrite: Boolean = false) {
    files.forEach {
      ModFileAdder.addModFile(it, skipOverwrite = skipOverwrite, log = this::appendLine)
      modTable.loadMods()
    }
    appendLine("All done!")
  }

  private fun refreshModFiles() {
    runWithConsole {
      ModFileAdder.refreshAllModInfo(::appendLine)
      appendLine("Done!")
    }
  }

  private fun removeSelectedMods() {
    transaction { removeMods(modTable.selectedItems.filterNot(UnpackedMod::isBaseGame).with(UnpackedMod::profiles)) }
  }

  private fun setSelectedModsEnabled(enabled: Boolean) {
    runWithConsole {
      val action = when {
        enabled -> "enabled"
        else -> "disabled"
      }
      val mods = modTable.selectedItems.filterNot(UnpackedMod::isBaseGame)
      when {
        mods.isEmpty() -> appendLine("No mods selected")
        else -> transaction {
          mods.forEach {
            it.enabled = enabled
            appendLine("${it.name} $action")
          }
          modTable.loadMods()
        }
      }
    }
  }

  private fun compareHTMLFiles() {
    val previousPathBox = JTextField().apply {
      columns = 30
      text = htmlFile.toString()
    }
    val currentPathBox = JTextField().apply {
      columns = 30
      text = moddedDir.resolve(htmlFile.name).toString()
    }
    val panel = buildPanel {
      add(previousPathBox)
      button("...") {
        chooseFile(setOf("html"), "HTML Files", parent = gui)?.let { previousPathBox.text = it.path }
      }
      text("->")
      add(currentPathBox)
      button("...") {
        chooseFile(setOf("html"), "HTML Files", parent = gui)?.let { currentPathBox.text = it.path }
      }
    }
    if (okCancel(panel, "Compare Versions", messageType = JOptionPane.PLAIN_MESSAGE)) {
      val previousPath = Path(previousPathBox.text)
      val currentPath = Path(currentPathBox.text)
      if (previousPath.notExists()) {
        JOptionPane.showMessageDialog(gui, "Previous version file does not exist", "ERROR", JOptionPane.ERROR_MESSAGE)
      } else if (currentPath.notExists()) {
        JOptionPane.showMessageDialog(gui, "Current version file does not exist", "ERROR", JOptionPane.ERROR_MESSAGE)
      } else {
        EscFrame.show("Compare", CompareHTMLFilesPanel(previousPath, currentPath))
      }
    }
  }

  private fun setModsLoadOrder() {
    data class ModDisplay(val mod: UnpackedMod) {
      override fun toString(): String = mod.name
    }
    val mods = transaction { UnpackedMod.find { UnpackedMods.id greater 0 }.sortedBy { it.name } }.map(::ModDisplay)
    val list1 = JList(mods.toTypedArray())
    val combo = JComboBox(arrayOf(ModLoadOrder.BEFORE, ModLoadOrder.AFTER))
    val list2 = JList(mods.toTypedArray())
    val panel = buildPanel {
      scroll(list1)
      text("loads")
      add(combo)
      scroll(list2)
    }
    if (okCancel(panel, "Add Manual Load Orders", messageType = JOptionPane.PLAIN_MESSAGE)) {
      runWithConsole {
        try {
          transaction {
            val loadOrder = combo.selectedItem as ModLoadOrder
            list1.selectedValuesList.map(ModDisplay::mod).forEach { unpackedMod ->
              list2.selectedValuesList.map { it.mod.name }.forEach { ref ->
                val existing = unpackedMod.references.find { it.otherModName == ref }
                when {
                  existing == null -> ModReference.new {
                    mod = unpackedMod
                    order = loadOrder
                    relationship = ModRelationship.COMPATIBLE
                    otherModName = ref
                    isManual = true
                  }
                  existing.isManual -> existing.order = loadOrder
                }
              }
            }
            ModProcessor.updateLoadOrders()
            modTable.loadMods()
          }
        } catch (e: CircularLoadOrderException) {
          appendLine("Specified rule would create a circular load order, aborting")
        }
      }
    }
  }

  private fun deleteSelectedModsLoadOrder() {
    transaction {
      data class ModReferenceDisplay(val reference: ModReference) {
        override fun toString(): String = "${reference.mod.name} loads ${reference.order} ${reference.otherModName}"
      }

      val refs = ModReference.find { ModReferences.isManual eq true }.map(::ModReferenceDisplay)
      val list = JList(refs.toTypedArray())
      val panel = buildPanel(axis = BoxLayout.PAGE_AXIS) {
        text("Select one or more manual load orders to delete")
        scroll(list)
      }
      if (okCancel(panel, "Delete Load Orders")) {
        list.selectedValuesList.map(ModReferenceDisplay::reference).forEach(ModReference::delete)
        ModProcessor.updateLoadOrders()
        modTable.loadMods()
      }
    }
  }

  fun removeMods(mods: Collection<UnpackedMod>) {
    runWithConsole {
      val removed = transaction {
        when {
          mods.isEmpty() -> null
          mods.size == 1 -> ModFileAdder.removeMod(mods.single(), log = ::appendLine)
          else -> ModFileAdder.removeMods(mods, log = ::appendLine)
        }
      }
      if (removed != false) {
        appendLine(when {
          removed == null -> "No mods selected"
          mods.size == 1 -> "${mods.single()} removed successfully"
          else -> "${mods.size} mods removed successfully"
        })
        modTable.loadMods()
      }
    }
  }

  // Aphrodite hasn't been releasing patch files, keep this around in case we ever do but for now it's confusing
  @Suppress("unused")
  private fun applyPatch(file: File) {
    runWithConsole {
      applyPatchFile(inputFile = defaultInputFilePath, file = file, log = ::appendLine)
    }
  }

  private fun setLaf(laf: LookAndFeelInfo) {
    Settings.laf = laf.name
    SwingUtilities.updateComponentTreeUI(this@GUI)
    consoleArea.setupAutoscroll()
  }

  private fun saveProfile() {
    runWithConsole {
      transaction {
        val combobox = profileComboBox(true)
        if (okCancel(combobox, "Name profile")) {
          val profileName = combobox.editor.item.toString()
          val existingProfile = ModProfile.find(ModProfiles.name eq profileName).singleOrNull()
          val profile = if (existingProfile == null) {
            appendLine("Saving new profile '$profileName'")
            ModProfile.new { name = profileName }
          } else {
            if (!confirm("Update existing profile ${existingProfile.name}?", "Overwrite?")) return@transaction
            appendLine("Updating existing profile '$profileName'")
            existingProfile
          }
          profile.mods = UnpackedMod.find(UnpackedMods.enabled eq true)
          appendLine("Done!")
        }
      }
    }
  }

  private fun loadProfile() {
    runWithConsole {
      transaction {
        chooseProfile()?.let { profile ->
          UnpackedMods.update { it[enabled] = false }
          profile.mods.forEach { it.enabled = true }
          modTable.loadMods()
          appendLine("Loaded $profile")
        }
      }
    }
  }

  private fun deleteProfile() {
    runWithConsole {
      transaction {
        chooseProfile()?.let {
          it.delete()
          appendLine("Deleted $it")
        }
      }
    }
  }

  private fun showAllPassages() {
    ModProcessor.updateLoadOrders()
    modTable.loadMods()

    EscFrame.show("Passages", buildPanel(axis = BoxLayout.PAGE_AXIS, preferredSize = Dimension(900, 400)) {
      add(DefinedTableFilterPanel(AllPassagesTable(), FinalMod::name, ::scroll))
    })
  }

  private fun showAllFiles() {
    ModProcessor.updateLoadOrders()
    modTable.loadMods()

    EscFrame.show("Files", buildPanel(axis = BoxLayout.PAGE_AXIS, preferredSize = Dimension(900, 400)) {
      add(DefinedTableFilterPanel(AllFilesTable(), FinalMod::name, ::scroll, AllFilesTable::fileTypeFilterer))
    })
  }

  private fun showAboutDialog(windowTitle: String) {
    JOptionPane.showMessageDialog(
      this@GUI,
      "$windowTitle\nVersion: ${AppProperties.appVersion}",
      "About",
      JOptionPane.INFORMATION_MESSAGE
    )
  }

  private fun validateMods(inputPath: Path = defaultInputFilePath) {
    runWithConsole {
      try {
        val orderMessages = ModProcessor.validateMods(inputPath = inputPath, log = ::appendLine)
        modTable.loadMods()
        val dialogContent = JPanel()
        dialogContent.layout = BoxLayout(dialogContent, BoxLayout.Y_AXIS)

        val errorView = ScrollPane()
        errorView.add(errorsAsTree(orderMessages, rootText = "Load Order", expand = false))
        errorView.setSize(500, 500)
        dialogContent.add(errorView)

        JOptionPane.showMessageDialog(this@GUI, dialogContent, "Mod load order", JOptionPane.INFORMATION_MESSAGE)
      } catch (e: MissingRequirementsException) {
        appendLine("Unable to load mods - Required mod(s) not found")
        e.requiredModNames.forEach { appendLine("  $it") }
      } catch (e: CircularLoadOrderException) {
        appendLine(circularMessage)
      }
    }
  }

  private fun loadMods(inputPath: Path = defaultInputFilePath) {
    runWithConsole {
      try {
        ModProcessor(::appendLine).loadMods(inputHtmlFile = inputPath, outputDirectory = moddedDir, Settings.copyInsteadOfLink)
          .takeIf { it.isNotEmpty() }?.let { conflicts ->
          val dialogContent = JPanel()
          dialogContent.layout = BoxLayout(dialogContent, BoxLayout.Y_AXIS)
          val message = "Some of the loaded mod files overlap. This may cause undefined behavior."
          dialogContent.add(JLabel(message))

          val errorView = ScrollPane()
          errorView.add(errorsAsTree(conflicts))
          errorView.setSize(300, 500)
          dialogContent.add(errorView)

          JOptionPane.showMessageDialog(this@GUI, dialogContent, "Conflicting mods", JOptionPane.INFORMATION_MESSAGE)
        }
        modTable.loadMods()
        SwingUtilities.invokeLater {
          if (Settings.runAfterLoad) try {
            Desktop.getDesktop().browse(URI(Settings.runAfterLoadURI))
          } catch (e: IOException) {
            JOptionPane.showMessageDialog(this@GUI, e.message, "Error", JOptionPane.ERROR_MESSAGE)
          }
          if (Settings.closeAfterLoad) close()
        }
      } catch (_: CircularLoadOrderException) {
        appendLine(circularMessage)
      } catch (_: LinksUnsupportedException) {
        val fileSystemType = Files.getFileStore(rootDir).type()
        val message = """
            File links are not supported on a file system formatted as $fileSystemType. In order to continue, you'll need to do one of two things:

             - Enable the "Load mods via copy instead of link" setting
               (slower, takes more space)

             - Move your game installation to a drive with a file system that does support links, e.g. NTFS
          """.trimIndent()
        val okOption = "OK"
        val settingsOption = "Open Settings"
        val enableSettingOption = "Re-run With Setting"

        val options = arrayOf(enableSettingOption, settingsOption, okOption)
        val response = JOptionPane.showOptionDialog(
          this@GUI,
          message,
          "Error",
          JOptionPane.OK_CANCEL_OPTION,
          JOptionPane.ERROR_MESSAGE,
          null,
          options,
          okOption
        )
        when (response) {
          options.indexOf(settingsOption) -> SettingsPanel.show(SettingsPanel::copyInsteadOfLinkBox)
          options.indexOf(enableSettingOption) -> {
            Settings.copyInsteadOfLink = true
            loadMods(inputPath)
          }
        }
        appendLine("Aborted - File links unsupported")
      }
    }
  }

  private fun errorsAsTree(errors: Map<String, List<String>>, rootText: String = "Overlaps", expand: Boolean = true): JTree {
    val root = DefaultMutableTreeNode(rootText)
    errors.forEach { (mod, conflicts) ->
      val modNode = DefaultMutableTreeNode(mod)
      val (children, noChildren) = conflicts.partition { it.contains(Checker.childrenText) }
      noChildren.map(::DefaultMutableTreeNode).forEach(modNode::add)
      children.forEach { child ->
        val (msg, childs)  = child.split(Checker.childrenText)
        val childrenNode = DefaultMutableTreeNode(msg).also(modNode::add)
        childs.split(Checker.childrenSeparator).map(::DefaultMutableTreeNode).forEach(childrenNode::add)
      }
      root.add(modNode)
    }
    val tree = JTree(root)
    val renderer = DefaultTreeCellRenderer()
    renderer.leafIcon = UIManager.getIcon("Tree.collapsedIcon")
    tree.cellRenderer = renderer
    if (expand) fullyExpandTree(tree)
    return tree
  }

  private fun runWithConsole(block: JTextArea.() -> Unit) {
    thread {
      with(consoleArea) {
        try {
          replaceRange("", 0, text.length)
          block()
        } catch (e: Exception) {
          appendLine(e.helpfulStackTrace())
        }
      }
    }
  }

  private fun showVersion2Message() {
    val message = """
      Welcome to version ${AppProperties.appVersion} of the mod loader! There have been some major changes since you last joined us.

      - Mod files now must be added through the Add Mods button. Mods still have to be loaded, but the add and load processes have been updated so that load times should be much faster.
      - Mod files can be deleted once you've added them. The add process unpacks the mod's files and stores the necessary information about it.
      - The load process has been changed to create links to existing files. This means your base game files remain untouched, and only one copy of each file has to exist.
      - With the load process changes, the mod output will now be in the 'modded' folder instead of creating a modded HTML file in the base directory.
      - There are some new settings to customize the add and load experiences. The defaults are set to give a very similar experience to the old versions.
      - Profiles! You can save and load which mods are enabled.

      Would you like to go ahead and import your existing mod list the new way?
    """.trimIndent()

    if (confirm(message, "New version!", this)) {
      runWithConsole {
        appendLine("Importing enabled mods\n")
        addMods(modsDir.listDirectoryEntries().filter { ModFileType.from(it) != null }.map(Path::toFile))
        Settings.enableModsOnAdd = false
        appendLine("\nImporting disabled mods\n")
        addMods(disabledDir.listDirectoryEntries().filter { ModFileType.from(it) != null }.map(Path::toFile), skipOverwrite = true)
        Settings.enableModsOnAdd = true
      }
    }
  }
}

tailrec fun fullyExpandTree(tree: JTree, start: Int = 0, total: Int = tree.rowCount) {
  IntRange(start, total - 1).forEach(tree::expandRow)
  if (tree.rowCount == total) return
  fullyExpandTree(tree, total, tree.rowCount)
}

fun JTextArea.appendLine(text: String = "") {
  append("$text\n")
}

fun confirm(message: Any, title: String, parent: Component? = gui, messageType: Int = JOptionPane.QUESTION_MESSAGE): Boolean =
  JOptionPane.showConfirmDialog(parent, message, title, JOptionPane.YES_NO_OPTION, messageType) == JOptionPane.YES_OPTION
fun okCancel(message: Any, title: String, parent: Component? = gui, messageType: Int = JOptionPane.QUESTION_MESSAGE): Boolean =
  JOptionPane.showConfirmDialog(parent, message, title, JOptionPane.OK_CANCEL_OPTION, messageType) == JOptionPane.OK_OPTION

fun buildFileChooser(extensions: Collection<String> = setOf(), description: String = "", root: File = rootDir.toFile(), allowMultiple: Boolean = false) =
  JFileChooser(root).apply {
    isMultiSelectionEnabled = allowMultiple
    if (extensions.isNotEmpty()) {
      fileFilter = object : FileFilter() {
        override fun accept(file: File?): Boolean = file?.isDirectory == true || file?.extension?.lowercase() in extensions.map(String::lowercase)
        override fun getDescription(): String = description
      }
    }
  }

fun chooseFiles(extensions: Collection<String>, description: String, parent: Component? = gui) =
  chooseFileList(extensions, description, parent, allowMultiple = true)

fun chooseFile(extensions: Collection<String>, description: String, parent: Component? = gui) =
  chooseFileList(extensions, description, parent, allowMultiple = false).singleOrNull()

private fun chooseFileList(extensions: Collection<String>, description: String, parent: Component? = gui, allowMultiple: Boolean = false): Collection<File> {
  val chooser = buildFileChooser(extensions, description, allowMultiple = allowMultiple)
  return when (chooser.showOpenDialog(parent)) {
    JFileChooser.APPROVE_OPTION -> when {
      allowMultiple -> chooser.selectedFiles.toList()
      else          -> listOf(chooser.selectedFile)
    }
    else                        -> listOf()
  }
}

fun chooseSaveFile(defaultName: String, root: File = rootDir.toFile(), parent: Component? = gui, onSave: (File) -> Unit) {
  val chooser = buildFileChooser().apply {
    selectedFile = File(root, defaultName)
    dialogTitle = "Save $defaultName"
  }

  if (chooser.showSaveDialog(parent) == JFileChooser.APPROVE_OPTION) {
    onSave(chooser.selectedFile)
  }
}

fun profileComboBox(editable: Boolean = false): JComboBox<ModProfile> =
  JComboBox(transaction { ModProfile.all().sortedBy(ModProfile::name).with(ModProfile::mods) }.toList().toTypedArray()).apply {
    isEditable = editable
    selectedItem = null
  }

fun chooseProfile(): ModProfile? {
  val box = profileComboBox()

  return if (okCancel(box, "Choose profile")) {
    box.selectedItem as? ModProfile
  } else null
}


code:
package life.xchange

import com.formdev.flatlaf.FlatDarculaLaf
import com.formdev.flatlaf.FlatIntelliJLaf
import life.xchange.cli.CLI
import life.xchange.cli.Operation
import life.xchange.compare.CompareHTMLFilesPanel
import life.xchange.db.ModDatabase
import life.xchange.db.UnpackedMod
import life.xchange.ui.CustomSplash
import life.xchange.ui.EscFrame
import life.xchange.util.helpfulStackTrace
import life.xchange.util.setHidden
import org.jetbrains.exposed.sql.Transaction
import org.jetbrains.exposed.sql.transactions.transaction
import java.io.File
import java.io.InputStream
import java.io.InputStreamReader
import java.io.StringReader
import java.nio.charset.MalformedInputException
import java.nio.charset.StandardCharsets
import java.nio.file.*
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
import javax.swing.JOptionPane
import javax.swing.UIManager
import javax.swing.UIManager.LookAndFeelInfo
import kotlin.io.path.*
import kotlin.properties.Delegates
import kotlin.system.exitProcess

lateinit var gui: GUI
  private set

lateinit var rootDir: Path
  private set
lateinit var jarFile: Path
  private set
lateinit var htmlFile: Path
  private set
lateinit var baseGame: UnpackedMod
  private set
val unpackedDir: Path by lazy { rootDir.resolve(".unpacked").absolute().normalize() }
val moddedDir: Path by lazy { rootDir.resolve("modded").absolute().normalize() }
val settingsFile: Path by lazy { rootDir.resolve(".mod-loader.settings").absolute().normalize() }
val modLoaderName: String by lazy { htmlFile.name.replace(".html", " Mod Loader").replace("X-Change", "X-Change™") }

val modsDir: Path by lazy { rootDir.resolve("mods") }
val disabledDir: Path by lazy { modsDir.resolve("disabled") }
val appliedDir: Path by lazy { modsDir.resolve("applied") }
val backupZip: Path by lazy { rootDir.resolve(".originals.zip") }
var fromVersion1X by Delegates.notNull<Boolean>()
  private set

private fun chooseBase(): File? {
  JOptionPane.showMessageDialog(null, """
    No base html file was found in the current directory.

    Please select the base html file to continue.
  """.trimIndent())
  return chooseFile(setOf("html"), "HTML Files", parent = null)
}

fun Transaction.upsertBaseGame() {
  baseGame = UnpackedMod.findById(0) ?: UnpackedMod.new(0) {
    name = "X-Change™ Life"
    author = "Aphrodite"
    enabled = true
    loadOrder = 0
  }
}

private val looksAndFeels = mapOf(
    "Dark" to LookAndFeelInfo("Dark", FlatDarculaLaf::class.java.name),
    "Light" to LookAndFeelInfo("Light", FlatIntelliJLaf::class.java.name),
    "Pink" to LookAndFeelInfo("Pink", PinkLaf::class.java.name))

fun reloadLaf() {
  carefully {
    looksAndFeels[Settings.laf]?.let { UIManager.setLookAndFeel(it.className) }
  }
}

fun carefully(block: () -> Unit) = try { block() } catch (_: Exception) {}

private val charsets = mutableListOf(
  StandardCharsets.UTF_8,
  StandardCharsets.UTF_16,
  StandardCharsets.ISO_8859_1,
  StandardCharsets.US_ASCII,
  StandardCharsets.UTF_16LE,
  StandardCharsets.UTF_16BE,
)

private inline fun <T> T.charSetSafeLineSequence(lineConsumer: (Sequence<String>) -> Unit, crossinline streamMaker: (T) -> InputStream) {
  var lastException = MalformedInputException(1)

  charsets.forEachIndexed { index, charset ->
    try {
      InputStreamReader(streamMaker(this), charset).useLines(lineConsumer)

      // Try this one first next time
      if (index > 0) {
        charsets.remove(charset)
        charsets.add(0, charset)
      }
      return
    } catch (e: MalformedInputException) {
      lastException = e
    }
  }

  throw lastException
}

private inline fun <T> T.charSetSafeRead(streamMaker: (T) -> InputStream): String {
  var lastException = MalformedInputException(1)

  charsets.forEachIndexed { index, charset ->
    try {
      val output = InputStreamReader(streamMaker(this), charset).use(InputStreamReader::readText)

      // Try this one first next time
      if (index > 0) {
        charsets.remove(charset)
        charsets.add(0, charset)
      }
      return output
    } catch (e: MalformedInputException) {
      lastException = e
    }
  }

  throw lastException
}

fun Path.charSetSafeRead(): String = charSetSafeRead<Path> { Files.newInputStream(this) }
fun ZipFile.charSetSafeRead(entry: ZipEntry): String = charSetSafeRead<ZipFile> { getInputStream(entry) }
fun Path.charSetSafeLineSequence(lineConsumer: (Sequence<String>) -> Unit) { charSetSafeLineSequence<Path>(lineConsumer) { Files.newInputStream(this) } }
fun ZipFile.charSetSafeLineSequence(entry: ZipEntry, lineConsumer: (Sequence<String>) -> Unit) { charSetSafeLineSequence<ZipFile>(lineConsumer) { getInputStream(entry) } }

fun main(args: Array<String>) {
  // Don't display splash screen if using CLI commands
  try {
    val cmd = CLI.parse(args)

    if (cmd.hasOption(CLI.help)) {
      CLI.showHelp()
      return
    }

    if (cmd.argList.isEmpty()) {
      CustomSplash.show()
    }

    UIManager.setInstalledLookAndFeels(looksAndFeels.values.toTypedArray())

    jarFile = when {
      cmd.hasOption(CLI.debug) -> Path("./mod-loader.jar")
      else -> File(GUI::class.java.protectionDomain.codeSource.location.toURI()).toPath()
    }
    rootDir = when {
      cmd.hasOption(CLI.root) -> cmd.getParsedOptionValue(CLI.root)
      cmd.hasOption(CLI.html) -> cmd.getParsedOptionValue<Path>(CLI.html).absolute().parent
      else -> jarFile.parent
    }.absolute().normalize()

    if (settingsFile.exists()) {
      carefully {
        // this is a bit weird but ensures we support all the old charsets
        val settingsText = settingsFile.charSetSafeRead()
        settings.load(StringReader(settingsText))
      }
    }

    if (Settings.settingsVersion < 1) {
      // ignore laf prior to settingsVersion 1
      settings.remove("laf")
      // delete the old file. we only need the settings file if the user actually changes settings, now
      settingsFile.deleteIfExists()
    }

    Settings.settingsVersion = SETTINGS_VERSION
    reloadLaf()

    htmlFile = cmd.getParsedOptionValue<Path>(CLI.html)?.absolute()?.also { err(it.notExists(), "HTML file '$it' does not exist") }
        ?: rootDir.resolve("index.html").takeIf { it.exists() }
        ?: rootDir.resolve("X-Change Life.html").takeIf { it.exists() }
        ?: rootDir.listDirectoryEntries("*.html").singleOrNull()
        ?: chooseBase()?.toPath()?.also { rootDir = it.parent.absolute().normalize() }
        ?: return

    if (!unpackedDir.exists()) {
      unpackedDir.createDirectories()
      unpackedDir.setHidden()
    }

    fromVersion1X = appliedDir.exists()
    cleanupOldStuff()

    val (outTrace, fileTrace) = when {
      cmd.hasOption(CLI.debug) -> 1 to 0
      else -> 0 to 1
    }

    ModDatabase.init(rootDir.resolve(".modLoader"), outTrace = outTrace, fileTrace = fileTrace)

    transaction {
      upsertBaseGame()
    }

    CustomSplash.close()
    Operation.dispatch(cmd.argList) {
      gui = GUI(htmlFile, modLoaderName)
    }
  } catch (e: Throwable) {
    CustomSplash.close()
    err(true, e.helpfulStackTrace())
  }
}

fun err(condition: Boolean, message: Any) {
  if (condition) {
    JOptionPane.showMessageDialog(null, message, "Error", JOptionPane.ERROR_MESSAGE)
    System.err.print(message)
    exitProcess(1)
  }
}

fun compare(previousPath: Path, currentPath: Path) {
  err(previousPath.notExists(), "File '$previousPath' does not exist")
  err(currentPath.notExists(), "File '$currentPath' does not exist")

  EscFrame.show("Compare Versions", CompareHTMLFilesPanel(previousPath, currentPath), parent = null)
}


code:
package life.xchange

import java.nio.file.Path
import kotlin.io.path.extension

enum class ModFileType(val isText: Boolean = false) {
  JS(true), TWEE(true), ZIP, XCL, META(true);

  val extension: String
    get() = name.lowercase()

  companion object {
    fun from(path: Path): ModFileType? = from(path.extension)
    fun from(extension: String): ModFileType? = try {
      ModFileType.valueOf(extension.uppercase())
    } catch (_: Exception) {
      null
    }
  }
}